Subh775 commited on
Commit
162e31e
·
1 Parent(s): e12bf3c

code refactoring: seperation of concerns; cleanup; structural organization..

Browse files
frontend/{rf.png → assets/rf.png} RENAMED
File without changes
frontend/{uf_rf.png → assets/uf_rf.png} RENAMED
File without changes
frontend/css/initial.css ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --cocoa: #8b5e3c;
3
+ --cocoa-l: #c89a6c;
4
+ --cocoa-xl: #d4b08a;
5
+ --t1: #f0ece6;
6
+ --t2: #a89f97;
7
+ --border: #2a2a2a;
8
+ }
9
+
10
+ body {
11
+ font-family: 'Montserrat', sans-serif;
12
+ background-color: #000000;
13
+ color: var(--t1);
14
+ }
15
+
16
+ .fade-in {
17
+ animation: fadeIn 0.4s ease-in-out forwards;
18
+ }
19
+
20
+ @keyframes fadeIn {
21
+ from {
22
+ opacity: 0;
23
+ transform: translateY(10px);
24
+ }
25
+
26
+ to {
27
+ opacity: 1;
28
+ transform: translateY(0);
29
+ }
30
+ }
31
+
32
+ /* Executive Overrides */
33
+ .traffic-dynamics-card {
34
+ background-color: #0a0a0a !important;
35
+ border: 2px solid var(--cocoa) !important;
36
+ }
37
+
38
+ .traffic-dynamics-card:hover {
39
+ border-color: var(--cocoa-l) !important;
40
+ }
41
+
42
+ #dropzone {
43
+ transition: all 0.2s ease;
44
+ border-color: #2a2a2a;
45
+ }
46
+
47
+ #dropzone:hover {
48
+ border-color: var(--cocoa-l) !important;
49
+ background-color: #0a0a0a !important;
50
+ }
51
+
52
+ .core-badge {
53
+ background-color: var(--cocoa) !important;
54
+ color: var(--t1) !important;
55
+ }
56
+
57
+ /* Onboarding */
58
+ .onboard-overlay {
59
+ position: fixed; inset: 0; z-index: 9999;
60
+ background: rgba(0,0,0,0.92);
61
+ display: flex; align-items: center; justify-content: center;
62
+ }
63
+ .onboard-card {
64
+ background: #0a0a0a; border: 1px solid #2a2a2a;
65
+ border-radius: 16px; max-width: 440px; width: 90%;
66
+ padding: 40px 32px; text-align: center;
67
+ }
68
+ .onboard-step { display: none; }
69
+ .onboard-step.active { display: block; }
70
+ .onboard-dots { display: flex; gap: 6px; justify-content: center; margin-top: 20px; }
71
+ .onboard-dot {
72
+ width: 8px; height: 8px; border-radius: 50%;
73
+ background: #333; transition: background 0.2s;
74
+ }
75
+ .onboard-dot.active { background: var(--cocoa-l); }
76
+
77
+ /* Mobile responsive */
78
+ @media (max-width: 768px) {
79
+ main { grid-template-columns: 1fr !important; padding: 16px !important; }
80
+ h1 { font-size: 2.2rem !important; }
81
+ }
frontend/css/vehicles.css ADDED
@@ -0,0 +1,450 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ :root {
3
+ --cocoa: #8b5e3c;
4
+ --cocoa-l: #c89a6c;
5
+ --cocoa-xl: #d4b08a;
6
+ }
7
+
8
+ body {
9
+ font-family: 'Montserrat', sans-serif;
10
+ background-color: #000000;
11
+ color: #f0ece6;
12
+ }
13
+
14
+ .mono-font {
15
+ font-family: 'JetBrains Mono', monospace;
16
+ }
17
+
18
+ ::-webkit-scrollbar {
19
+ width: 4px;
20
+ height: 4px;
21
+ }
22
+
23
+ ::-webkit-scrollbar-track {
24
+ background: #000000;
25
+ }
26
+
27
+ ::-webkit-scrollbar-thumb {
28
+ background: #222222;
29
+ border-radius: 4px;
30
+ }
31
+
32
+ ::-webkit-scrollbar-thumb:hover {
33
+ background: #333333;
34
+ }
35
+
36
+ .info-wrap {
37
+ position: relative;
38
+ display: inline-flex;
39
+ align-items: center;
40
+ margin-left: 6px;
41
+ }
42
+
43
+ .info-btn {
44
+ display: inline-flex;
45
+ align-items: center;
46
+ justify-content: center;
47
+ width: 14px;
48
+ height: 14px;
49
+ border-radius: 50%;
50
+ background: #444444 !important;
51
+ color: #ffffff !important;
52
+ font-size: 7px;
53
+ cursor: pointer;
54
+ transition: all 0.2s ease;
55
+ }
56
+
57
+ .info-btn:hover {
58
+ background: #666666 !important;
59
+ }
60
+
61
+ .info-tip {
62
+ display: none;
63
+ position: fixed;
64
+ z-index: 9999;
65
+ background: #0a0a0a;
66
+ color: #aaaaaa;
67
+ font-size: 10px;
68
+ font-weight: 500;
69
+ line-height: 1.4;
70
+ padding: 8px 12px;
71
+ border-radius: 6px;
72
+ max-width: 240px;
73
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.8);
74
+ border: 1px solid #222222;
75
+ pointer-events: none;
76
+ text-transform: none;
77
+ letter-spacing: normal;
78
+ }
79
+
80
+ /* Nav states */
81
+ .nav-item-active {
82
+ background-color: #111111 !important;
83
+ color: var(--cocoa-xl) !important;
84
+ border-left: 2px solid var(--cocoa-l) !important;
85
+ }
86
+
87
+ .nav-item-inactive {
88
+ color: #555555 !important;
89
+ }
90
+
91
+ .nav-item-inactive:hover {
92
+ color: #f0ece6 !important;
93
+ background-color: #050505 !important;
94
+ }
95
+
96
+ /* Card Overrides */
97
+ .bg-white {
98
+ background-color: #0a0a0a !important;
99
+ }
100
+
101
+ .border-slate-200,
102
+ .border-slate-100,
103
+ .border-slate-50,
104
+ .border-neutral-800,
105
+ .border-neutral-900 {
106
+ border-color: #2a2a2a !important;
107
+ }
108
+
109
+ .bg-slate-50\/50,
110
+ .bg-slate-50,
111
+ .bg-slate-900,
112
+ .bg-neutral-900 {
113
+ background-color: #0c0c0c !important;
114
+ }
115
+
116
+ .text-slate-900,
117
+ .text-slate-800,
118
+ .text-slate-700,
119
+ .text-neutral-900 {
120
+ color: #ffffff !important;
121
+ }
122
+
123
+ .text-slate-600,
124
+ .text-slate-500,
125
+ .text-slate-400,
126
+ .text-neutral-500,
127
+ .text-neutral-400 {
128
+ color: #888888 !important;
129
+ }
130
+
131
+ .shadow-sm {
132
+ box-shadow: none !important;
133
+ }
134
+
135
+ /* Controls */
136
+ .toggle-track {
137
+ width: 32px;
138
+ height: 18px;
139
+ border-radius: 9px;
140
+ background: #222222;
141
+ position: relative;
142
+ cursor: pointer;
143
+ }
144
+
145
+ .toggle-track.active {
146
+ background: var(--cocoa-l);
147
+ }
148
+
149
+ .toggle-thumb {
150
+ width: 14px;
151
+ height: 14px;
152
+ border-radius: 50%;
153
+ background: #555555;
154
+ position: absolute;
155
+ top: 2px;
156
+ left: 2px;
157
+ transition: all 0.2s ease;
158
+ }
159
+
160
+ .toggle-track.active .toggle-thumb {
161
+ transform: translateX(14px);
162
+ background: #000000;
163
+ }
164
+
165
+ .custom-select {
166
+ appearance: none;
167
+ background-color: #111111;
168
+ border: 1px solid #222222;
169
+ border-radius: 6px;
170
+ padding: 4px 24px 4px 10px;
171
+ font-size: 11px;
172
+ font-weight: 600;
173
+ color: #ffffff;
174
+ outline: none;
175
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='%23666666'%3E%3Cpath fill-rule='evenodd' d='M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z'/%3E%3C/svg%3E");
176
+ background-repeat: no-repeat;
177
+ background-position: right 8px center;
178
+ background-size: 12px;
179
+ }
180
+
181
+ .s-stepper {
182
+ display: inline-flex;
183
+ border: 1px solid #222222;
184
+ border-radius: 6px;
185
+ background: #111111;
186
+ overflow: hidden;
187
+ }
188
+
189
+ .s-stepper button {
190
+ padding: 4px 8px;
191
+ color: #666666;
192
+ font-size: 12px;
193
+ }
194
+
195
+ .s-stepper button:hover {
196
+ background: #1a1a1a;
197
+ color: #ffffff;
198
+ }
199
+
200
+ .s-stepper .s-val {
201
+ min-width: 40px;
202
+ text-align: center;
203
+ font-family: 'JetBrains Mono', monospace;
204
+ font-size: 12px;
205
+ font-weight: 700;
206
+ color: #ffffff;
207
+ padding: 4px 0;
208
+ border-left: 1px solid #222222;
209
+ border-right: 1px solid #222222;
210
+ }
211
+
212
+ .s-row {
213
+ display: flex;
214
+ align-items: center;
215
+ justify-content: space-between;
216
+ padding: 12px 0;
217
+ border-bottom: 1px solid #1a1a1a;
218
+ }
219
+
220
+ .s-row:last-child {
221
+ border-bottom: none;
222
+ }
223
+
224
+ #proc-bar {
225
+ background-color: var(--cocoa-l) !important;
226
+ }
227
+
228
+ #proc-label {
229
+ color: #ffffff !important;
230
+ }
231
+
232
+ .s-row.disabled {
233
+ opacity: 0.65 !important;
234
+ }
235
+
236
+ .s-row.disabled .s-stepper,
237
+ .s-row.disabled .custom-select,
238
+ .s-row.disabled .toggle-track,
239
+ .s-row.disabled .chip-container {
240
+ pointer-events: none !important;
241
+ opacity: 0.5 !important;
242
+ }
243
+
244
+ .s-row.disabled .info-wrap {
245
+ pointer-events: auto !important;
246
+ opacity: 1 !important;
247
+ }
248
+
249
+
250
+ #btn-start-processing {
251
+ font-family: 'Montserrat', sans-serif !important;
252
+ }
253
+
254
+ /* Chips */
255
+ .chip-container {
256
+ display: flex;
257
+ flex-wrap: wrap;
258
+ gap: 8px;
259
+ margin-top: 12px;
260
+ padding-top: 12px;
261
+ border-top: 1px solid #1a1a1a;
262
+ transition: all 0.3s ease;
263
+ }
264
+
265
+ .chip {
266
+ display: inline-flex;
267
+ align-items: center;
268
+ gap: 6px;
269
+ padding: 6px 14px;
270
+ border-radius: 9999px;
271
+ font-size: 10px;
272
+ font-weight: 700;
273
+ cursor: pointer;
274
+ transition: all 0.2s ease;
275
+ user-select: none;
276
+ border: 1px solid #333333;
277
+ background: rgba(255, 255, 255, 0.03);
278
+ color: #888888;
279
+ }
280
+
281
+ .chip.active {
282
+ background: var(--cocoa-l);
283
+ color: #000000;
284
+ border-color: var(--cocoa-l);
285
+ }
286
+
287
+ .chip.frozen {
288
+ background: rgba(255, 255, 255, 0.4);
289
+ color: #000000;
290
+ border-color: transparent;
291
+ cursor: default !important;
292
+ pointer-events: none;
293
+ }
294
+
295
+ .chip:hover {
296
+ border-color: #666666;
297
+ }
298
+
299
+ .chip.active:hover {
300
+ background: var(--cocoa-xl);
301
+ }
302
+
303
+ .chip i {
304
+ font-size: 9px;
305
+ }
306
+
307
+ .hidden-chip-container {
308
+ height: 0;
309
+ opacity: 0;
310
+ overflow: hidden;
311
+ margin-top: 0;
312
+ padding-top: 0;
313
+ border-top: none;
314
+ }
315
+
316
+ /* Toast Notifications */
317
+ #toast-container {
318
+ position: fixed;
319
+ bottom: 20px;
320
+ right: 20px;
321
+ z-index: 10000;
322
+ display: flex;
323
+ flex-direction: column;
324
+ gap: 8px;
325
+ pointer-events: none;
326
+ }
327
+ .toast {
328
+ background: #111;
329
+ border: 1px solid #2a2a2a;
330
+ color: #f0ece6;
331
+ font-size: 11px;
332
+ font-weight: 600;
333
+ padding: 10px 18px;
334
+ border-radius: 10px;
335
+ display: flex;
336
+ align-items: center;
337
+ gap: 8px;
338
+ pointer-events: auto;
339
+ animation: toastIn 0.3s ease-out;
340
+ max-width: 320px;
341
+ }
342
+ .toast.toast-out { animation: toastOut 0.3s ease-in forwards; }
343
+ .toast-success { border-color: #166534; }
344
+ .toast-success i { color: #22c55e; }
345
+ .toast-error { border-color: #7f1d1d; }
346
+ .toast-error i { color: #ef4444; }
347
+ .toast-info i { color: var(--cocoa-l); }
348
+ @keyframes toastIn { from { opacity: 0; transform: translateX(40px); } to { opacity: 1; transform: translateX(0); } }
349
+ @keyframes toastOut { from { opacity: 1; } to { opacity: 0; transform: translateX(40px); } }
350
+
351
+ /* Stats Empty State */
352
+ .stats-empty-overlay {
353
+ position: absolute;
354
+ inset: 0;
355
+ z-index: 50;
356
+ display: flex;
357
+ flex-direction: column;
358
+ align-items: center;
359
+ justify-content: center;
360
+ background: rgba(10, 10, 10, 0.85);
361
+ backdrop-filter: blur(8px);
362
+ border-radius: 12px;
363
+ }
364
+
365
+ /* Mobile Responsive */
366
+ @media (max-width: 1024px) {
367
+ aside.w-60 { display: none; }
368
+ .mobile-nav { display: flex !important; }
369
+ #tab-overview { grid-template-columns: repeat(1, 1fr) !important; }
370
+ #tab-overview > div { grid-column: span 1 !important; }
371
+ }
372
+ @media (max-width: 768px) {
373
+ .grid-cols-3 { grid-template-columns: 1fr !important; }
374
+ .grid-cols-2 { grid-template-columns: 1fr !important; }
375
+ main { padding: 8px !important; }
376
+ }
377
+
378
+ /* Feedback form */
379
+ .fb-textarea {
380
+ background: #111;
381
+ border: 1px solid #2a2a2a;
382
+ border-radius: 8px;
383
+ color: #f0ece6;
384
+ font-size: 12px;
385
+ padding: 12px;
386
+ width: 100%;
387
+ min-height: 120px;
388
+ resize: vertical;
389
+ font-family: 'Inter', sans-serif;
390
+ }
391
+ .fb-textarea:focus { outline: none; border-color: var(--cocoa-l); }
392
+ .fb-select {
393
+ background: #111;
394
+ border: 1px solid #2a2a2a;
395
+ border-radius: 8px;
396
+ color: #f0ece6;
397
+ font-size: 11px;
398
+ padding: 8px 12px;
399
+ width: 100%;
400
+ font-family: 'Inter', sans-serif;
401
+ }
402
+ .fb-select:focus { outline: none; border-color: var(--cocoa-l); }
403
+ .fb-stars { display: flex; gap: 6px; }
404
+ .fb-star {
405
+ font-size: 22px;
406
+ color: #333;
407
+ cursor: pointer;
408
+ transition: color 0.15s;
409
+ }
410
+ .fb-star.active, .fb-star:hover { color: var(--cocoa-l); }
411
+ .fb-chip {
412
+ background: #050505;
413
+ border: 1px solid #222;
414
+ border-radius: 8px;
415
+ color: #666;
416
+ font-size: 10px;
417
+ font-weight: 700;
418
+ padding: 12px;
419
+ cursor: pointer;
420
+ transition: all 0.2s ease;
421
+ text-align: center;
422
+ text-transform: uppercase;
423
+ tracking-widest: 0.05em;
424
+ }
425
+ .fb-chip:hover { border-color: #444; color: #999; }
426
+ .fb-chip.active {
427
+ border-color: var(--cocoa-l);
428
+ background: #111;
429
+ color: #fff;
430
+ box-shadow: 0 0 15px rgba(200, 154, 108, 0.15);
431
+ }
432
+ .fb-emoji-btn {
433
+ background: #111;
434
+ border: 1px solid #2a2a2a;
435
+ border-radius: 8px;
436
+ color: #555;
437
+ flex: 1;
438
+ text-align: center;
439
+ padding: 10px 4px;
440
+ cursor: pointer;
441
+ transition: all 0.2s ease;
442
+ }
443
+ .fb-emoji-btn:hover { border-color: #444; color: #888; }
444
+ .fb-emoji-btn.active {
445
+ border-color: var(--cocoa-l);
446
+ background: #1a1a1a;
447
+ color: var(--cocoa-l);
448
+ box-shadow: 0 0 15px rgba(200, 154, 108, 0.15);
449
+ }
450
+
frontend/initial.html CHANGED
@@ -18,102 +18,20 @@
18
  <meta charset="UTF-8">
19
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
20
  <title>UrbanFlow</title>
21
- <link rel="icon" type="image/svg+xml" href="rf.png">
22
  <script src="https://cdn.tailwindcss.com"></script>
23
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
24
  <link
25
  href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Montserrat:wght@400;500;600;700;800;900&display=swap"
26
  rel="stylesheet">
27
- <style>
28
- :root {
29
- --cocoa: #8b5e3c;
30
- --cocoa-l: #c89a6c;
31
- --cocoa-xl: #d4b08a;
32
- --t1: #f0ece6;
33
- --t2: #a89f97;
34
- --border: #2a2a2a;
35
- }
36
-
37
- body {
38
- font-family: 'Montserrat', sans-serif;
39
- background-color: #000000;
40
- color: var(--t1);
41
- }
42
-
43
- .fade-in {
44
- animation: fadeIn 0.4s ease-in-out forwards;
45
- }
46
-
47
- @keyframes fadeIn {
48
- from {
49
- opacity: 0;
50
- transform: translateY(10px);
51
- }
52
-
53
- to {
54
- opacity: 1;
55
- transform: translateY(0);
56
- }
57
- }
58
-
59
- /* Executive Overrides */
60
- .traffic-dynamics-card {
61
- background-color: #0a0a0a !important;
62
- border: 2px solid var(--cocoa) !important;
63
- }
64
-
65
- .traffic-dynamics-card:hover {
66
- border-color: var(--cocoa-l) !important;
67
- }
68
-
69
- #dropzone {
70
- transition: all 0.2s ease;
71
- border-color: #2a2a2a;
72
- }
73
-
74
- #dropzone:hover {
75
- border-color: var(--cocoa-l) !important;
76
- background-color: #0a0a0a !important;
77
- }
78
-
79
- .core-badge {
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
 
112
  <body
113
  class="bg-black text-white min-h-screen w-full flex flex-col items-center selection:bg-white selection:text-black">
114
 
115
  <header class="mt-16 flex flex-col items-center flex-shrink-0 w-full z-10">
116
- <img src="uf_rf.png" alt="UrbanFlow Logo" class="h-44 md:h-52 w-auto object-contain mb-3">
117
  </header>
118
 
119
  <main
@@ -218,192 +136,7 @@
218
  </div>
219
  </main>
220
 
221
- <script>
222
- let videoId = null;
223
- let runConfig = {};
224
-
225
- function showStep(name) {
226
- ['modules', 'upload', 'draw'].forEach(s => {
227
- const el = document.getElementById('step-' + s);
228
- if (el) el.classList.add('hidden');
229
- });
230
- const target = document.getElementById('step-' + name);
231
- if (target) target.classList.remove('hidden');
232
-
233
- if (name === 'upload') {
234
- document.getElementById('upload-progress-container').classList.add('hidden');
235
- document.getElementById('dropzone').classList.remove('hidden');
236
- // Reset Progress Bar state for new uploads
237
- document.getElementById('upload-bar').style.width = '0%';
238
- document.getElementById('upload-percentage').innerText = '0%';
239
- document.getElementById('upload-text').innerText = 'Uploading...';
240
- document.getElementById('upload-text').classList.remove('text-red-500');
241
- }
242
- if (name === 'draw') loadFirstFrame();
243
- }
244
-
245
- const dropzone = document.getElementById('dropzone');
246
- const fileInput = document.getElementById('file-input');
247
-
248
- if (fileInput) {
249
- fileInput.addEventListener('change', () => {
250
- if (fileInput.files.length) uploadFile(fileInput.files[0]);
251
- });
252
- }
253
-
254
- if (dropzone) {
255
- dropzone.addEventListener('dragover', e => { e.preventDefault(); dropzone.classList.add('border-white', 'bg-neutral-950'); });
256
- dropzone.addEventListener('dragleave', () => dropzone.classList.remove('border-white', 'bg-neutral-950'));
257
- dropzone.addEventListener('drop', e => {
258
- e.preventDefault();
259
- dropzone.classList.remove('border-white', 'bg-neutral-950');
260
- if (e.dataTransfer.files.length) uploadFile(e.dataTransfer.files[0]);
261
- });
262
- }
263
-
264
- let currentXHR = null;
265
- function uploadFile(file) {
266
- // Abort previous upload if it exists to prevent jitter/multiple requests
267
- if (currentXHR) currentXHR.abort();
268
-
269
- const dropzoneEl = document.getElementById('dropzone');
270
- const prog = document.getElementById('upload-progress-container');
271
- const bar = document.getElementById('upload-bar');
272
- const pct = document.getElementById('upload-percentage');
273
- const txt = document.getElementById('upload-text');
274
-
275
- if (dropzoneEl) dropzoneEl.classList.add('hidden');
276
- if (prog) prog.classList.remove('hidden');
277
-
278
- const form = new FormData();
279
- form.append('file', file);
280
-
281
- const xhr = new XMLHttpRequest();
282
- currentXHR = xhr;
283
- xhr.open('POST', '/upload');
284
-
285
- xhr.upload.onprogress = e => {
286
- if (e.lengthComputable) {
287
- const p = Math.round(e.loaded / e.total * 100);
288
- bar.style.width = p + '%';
289
- pct.innerText = p + '%';
290
- }
291
- };
292
-
293
- xhr.onerror = () => {
294
- txt.innerText = 'Error: Network failure';
295
- txt.classList.add('text-red-500');
296
- fileInput.value = '';
297
- };
298
-
299
- xhr.onload = () => {
300
- if (xhr.status !== 200) {
301
- txt.innerText = 'Error: ' + xhr.status;
302
- txt.classList.add('text-red-500');
303
- fileInput.value = '';
304
- return;
305
- }
306
- const res = JSON.parse(xhr.responseText);
307
- videoId = res.video_id;
308
- txt.innerText = 'Extracting Metadata...';
309
- bar.style.width = '100%';
310
- pct.innerText = '100%';
311
-
312
- fetch('/config/' + videoId)
313
- .then(r => r.json())
314
- .then(cfg => {
315
- runConfig = cfg;
316
- runConfig.conf = 0.12;
317
- runConfig.iou = 0.60;
318
- txt.innerText = 'Initialization Complete';
319
- fileInput.value = '';
320
- setTimeout(() => showStep('draw'), 800);
321
- })
322
- .catch(e => {
323
- txt.innerText = 'Metadata Failed';
324
- txt.classList.add('text-red-500');
325
- fileInput.value = '';
326
- });
327
- };
328
- xhr.send(form);
329
- }
330
-
331
- // Draw Canvas Logic
332
- const canvas = document.getElementById('drawing-canvas');
333
- const ctx = canvas.getContext('2d');
334
- let points = [];
335
- let imgNatW = 0, imgNatH = 0;
336
-
337
- function loadFirstFrame() {
338
- const img = document.getElementById('frame-img');
339
- img.src = '/first-frame/' + videoId;
340
- img.onload = () => {
341
- imgNatW = img.naturalWidth;
342
- imgNatH = img.naturalHeight;
343
- img.style.display = 'block';
344
- document.getElementById('frame-placeholder').style.display = 'none';
345
- initCanvas();
346
- };
347
- }
348
-
349
- function initCanvas() {
350
- if (canvas) {
351
- canvas.width = canvas.offsetWidth;
352
- canvas.height = canvas.offsetHeight;
353
- }
354
- }
355
-
356
- window.addEventListener('resize', initCanvas);
357
-
358
- if (canvas) {
359
- canvas.addEventListener('mousedown', e => {
360
- if (points.length >= 2) return;
361
- const rect = canvas.getBoundingClientRect();
362
- const cx = e.clientX - rect.left;
363
- const cy = e.clientY - rect.top;
364
- const rx = (cx / canvas.width) * imgNatW;
365
- const ry = (cy / canvas.height) * imgNatH;
366
- points.push({ cx, cy, rx: Math.round(rx), ry: Math.round(ry) });
367
- drawDot(cx, cy);
368
- if (points.length === 2) drawLine();
369
- });
370
- }
371
-
372
- function drawDot(x, y) {
373
- ctx.beginPath();
374
- ctx.arc(x, y, 5, 0, Math.PI * 2);
375
- ctx.fillStyle = '#c89a6c';
376
- ctx.fill();
377
- ctx.strokeStyle = '#f0ece6';
378
- ctx.lineWidth = 2;
379
- ctx.stroke();
380
- }
381
-
382
- function drawLine() {
383
- ctx.beginPath();
384
- ctx.moveTo(points[0].cx, points[0].cy);
385
- ctx.lineTo(points[1].cx, points[1].cy);
386
- ctx.strokeStyle = '#c89a6c';
387
- ctx.lineWidth = 3;
388
- ctx.stroke();
389
- }
390
-
391
- function resetCanvas() {
392
- points = [];
393
- ctx.clearRect(0, 0, canvas.width, canvas.height);
394
- }
395
-
396
- function startRun() {
397
- if (points.length < 2) return;
398
- const line = [[points[0].rx, points[0].ry], [points[1].rx, points[1].ry]];
399
- sessionStorage.setItem('funky_run', JSON.stringify({
400
- video_id: videoId,
401
- line: line,
402
- config: runConfig
403
- }));
404
- window.location.href = '/';
405
- }
406
- </script>
407
 
408
  <!-- Onboarding Walkthrough -->
409
  <div id="onboard-overlay" class="onboard-overlay" style="display:none">
@@ -434,22 +167,7 @@
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
- }
449
-
450
- // Show onboarding on every page load
451
- document.getElementById('onboard-overlay').style.display = 'flex';
452
- </script>
453
  </body>
454
 
455
  </html>
 
18
  <meta charset="UTF-8">
19
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
20
  <title>UrbanFlow</title>
21
+ <link rel="icon" type="image/svg+xml" href="/assets/rf.png">
22
  <script src="https://cdn.tailwindcss.com"></script>
23
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
24
  <link
25
  href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Montserrat:wght@400;500;600;700;800;900&display=swap"
26
  rel="stylesheet">
27
+ <link rel="stylesheet" href="/css/initial.css">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  </head>
29
 
30
  <body
31
  class="bg-black text-white min-h-screen w-full flex flex-col items-center selection:bg-white selection:text-black">
32
 
33
  <header class="mt-16 flex flex-col items-center flex-shrink-0 w-full z-10">
34
+ <img src="/assets/uf_rf.png" alt="UrbanFlow Logo" class="h-44 md:h-52 w-auto object-contain mb-3">
35
  </header>
36
 
37
  <main
 
136
  </div>
137
  </main>
138
 
139
+ <script src="/js/initial.js"></script>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
 
141
  <!-- Onboarding Walkthrough -->
142
  <div id="onboard-overlay" class="onboard-overlay" style="display:none">
 
167
  </div>
168
  </div>
169
  </div>
170
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
171
  </body>
172
 
173
  </html>
frontend/js/initial.js ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ let videoId = null;
2
+ let runConfig = {};
3
+
4
+ function showStep(name) {
5
+ ['modules', 'upload', 'draw'].forEach(s => {
6
+ const el = document.getElementById('step-' + s);
7
+ if (el) el.classList.add('hidden');
8
+ });
9
+ const target = document.getElementById('step-' + name);
10
+ if (target) target.classList.remove('hidden');
11
+
12
+ if (name === 'upload') {
13
+ document.getElementById('upload-progress-container').classList.add('hidden');
14
+ document.getElementById('dropzone').classList.remove('hidden');
15
+ // Reset Progress Bar state for new uploads
16
+ document.getElementById('upload-bar').style.width = '0%';
17
+ document.getElementById('upload-percentage').innerText = '0%';
18
+ document.getElementById('upload-text').innerText = 'Uploading...';
19
+ document.getElementById('upload-text').classList.remove('text-red-500');
20
+ }
21
+ if (name === 'draw') loadFirstFrame();
22
+ }
23
+
24
+ const dropzone = document.getElementById('dropzone');
25
+ const fileInput = document.getElementById('file-input');
26
+
27
+ if (fileInput) {
28
+ fileInput.addEventListener('change', () => {
29
+ if (fileInput.files.length) uploadFile(fileInput.files[0]);
30
+ });
31
+ }
32
+
33
+ if (dropzone) {
34
+ dropzone.addEventListener('dragover', e => { e.preventDefault(); dropzone.classList.add('border-white', 'bg-neutral-950'); });
35
+ dropzone.addEventListener('dragleave', () => dropzone.classList.remove('border-white', 'bg-neutral-950'));
36
+ dropzone.addEventListener('drop', e => {
37
+ e.preventDefault();
38
+ dropzone.classList.remove('border-white', 'bg-neutral-950');
39
+ if (e.dataTransfer.files.length) uploadFile(e.dataTransfer.files[0]);
40
+ });
41
+ }
42
+
43
+ let currentXHR = null;
44
+ function uploadFile(file) {
45
+ // Abort previous upload if it exists to prevent jitter/multiple requests
46
+ if (currentXHR) currentXHR.abort();
47
+
48
+ const dropzoneEl = document.getElementById('dropzone');
49
+ const prog = document.getElementById('upload-progress-container');
50
+ const bar = document.getElementById('upload-bar');
51
+ const pct = document.getElementById('upload-percentage');
52
+ const txt = document.getElementById('upload-text');
53
+
54
+ if (dropzoneEl) dropzoneEl.classList.add('hidden');
55
+ if (prog) prog.classList.remove('hidden');
56
+
57
+ const form = new FormData();
58
+ form.append('file', file);
59
+
60
+ const xhr = new XMLHttpRequest();
61
+ currentXHR = xhr;
62
+ xhr.open('POST', '/upload');
63
+
64
+ xhr.upload.onprogress = e => {
65
+ if (e.lengthComputable) {
66
+ const p = Math.round(e.loaded / e.total * 100);
67
+ bar.style.width = p + '%';
68
+ pct.innerText = p + '%';
69
+ }
70
+ };
71
+
72
+ xhr.onerror = () => {
73
+ txt.innerText = 'Error: Network failure';
74
+ txt.classList.add('text-red-500');
75
+ fileInput.value = '';
76
+ };
77
+
78
+ xhr.onload = () => {
79
+ if (xhr.status !== 200) {
80
+ txt.innerText = 'Error: ' + xhr.status;
81
+ txt.classList.add('text-red-500');
82
+ fileInput.value = '';
83
+ return;
84
+ }
85
+ const res = JSON.parse(xhr.responseText);
86
+ videoId = res.video_id;
87
+ txt.innerText = 'Extracting Metadata...';
88
+ bar.style.width = '100%';
89
+ pct.innerText = '100%';
90
+
91
+ fetch('/config/' + videoId)
92
+ .then(r => r.json())
93
+ .then(cfg => {
94
+ runConfig = cfg;
95
+ runConfig.conf = 0.12;
96
+ runConfig.iou = 0.60;
97
+ txt.innerText = 'Initialization Complete';
98
+ fileInput.value = '';
99
+ setTimeout(() => showStep('draw'), 800);
100
+ })
101
+ .catch(e => {
102
+ txt.innerText = 'Metadata Failed';
103
+ txt.classList.add('text-red-500');
104
+ fileInput.value = '';
105
+ });
106
+ };
107
+ xhr.send(form);
108
+ }
109
+
110
+ // Draw Canvas Logic
111
+ const canvas = document.getElementById('drawing-canvas');
112
+ const ctx = canvas.getContext('2d');
113
+ let points = [];
114
+ let imgNatW = 0, imgNatH = 0;
115
+
116
+ function loadFirstFrame() {
117
+ const img = document.getElementById('frame-img');
118
+ img.src = '/first-frame/' + videoId;
119
+ img.onload = () => {
120
+ imgNatW = img.naturalWidth;
121
+ imgNatH = img.naturalHeight;
122
+ img.style.display = 'block';
123
+ document.getElementById('frame-placeholder').style.display = 'none';
124
+ initCanvas();
125
+ };
126
+ }
127
+
128
+ function initCanvas() {
129
+ if (canvas) {
130
+ canvas.width = canvas.offsetWidth;
131
+ canvas.height = canvas.offsetHeight;
132
+ }
133
+ }
134
+
135
+ window.addEventListener('resize', initCanvas);
136
+
137
+ if (canvas) {
138
+ canvas.addEventListener('mousedown', e => {
139
+ if (points.length >= 2) return;
140
+ const rect = canvas.getBoundingClientRect();
141
+ const cx = e.clientX - rect.left;
142
+ const cy = e.clientY - rect.top;
143
+ const rx = (cx / canvas.width) * imgNatW;
144
+ const ry = (cy / canvas.height) * imgNatH;
145
+ points.push({ cx, cy, rx: Math.round(rx), ry: Math.round(ry) });
146
+ drawDot(cx, cy);
147
+ if (points.length === 2) drawLine();
148
+ });
149
+ }
150
+
151
+ function drawDot(x, y) {
152
+ ctx.beginPath();
153
+ ctx.arc(x, y, 5, 0, Math.PI * 2);
154
+ ctx.fillStyle = '#c89a6c';
155
+ ctx.fill();
156
+ ctx.strokeStyle = '#f0ece6';
157
+ ctx.lineWidth = 2;
158
+ ctx.stroke();
159
+ }
160
+
161
+ function drawLine() {
162
+ ctx.beginPath();
163
+ ctx.moveTo(points[0].cx, points[0].cy);
164
+ ctx.lineTo(points[1].cx, points[1].cy);
165
+ ctx.strokeStyle = '#c89a6c';
166
+ ctx.lineWidth = 3;
167
+ ctx.stroke();
168
+ }
169
+
170
+ function resetCanvas() {
171
+ points = [];
172
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
173
+ }
174
+
175
+ function startRun() {
176
+ if (points.length < 2) return;
177
+ const line = [[points[0].rx, points[0].ry], [points[1].rx, points[1].ry]];
178
+ sessionStorage.setItem('funky_run', JSON.stringify({
179
+ video_id: videoId,
180
+ line: line,
181
+ config: runConfig
182
+ }));
183
+ window.location.href = '/';
184
+ }
185
+
186
+ let _obStep = 0;
187
+ function nextOnboardStep() {
188
+ _obStep++;
189
+ if (_obStep >= 3) { closeOnboarding(); return; }
190
+ document.querySelectorAll('.onboard-step').forEach((s, i) => s.classList.toggle('active', i === _obStep));
191
+ document.querySelectorAll('.onboard-dot').forEach((d, i) => d.classList.toggle('active', i === _obStep));
192
+ if (_obStep === 2) document.getElementById('onboard-next').innerText = 'Get Started';
193
+ }
194
+ function closeOnboarding() {
195
+ document.getElementById('onboard-overlay').style.display = 'none';
196
+ }
197
+
198
+ // Show onboarding on every page load
199
+ document.addEventListener("DOMContentLoaded", () => {
200
+ document.getElementById('onboard-overlay').style.display = 'flex';
201
+ });
frontend/js/vehicles.js ADDED
@@ -0,0 +1,1058 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ // =========== Rolling Counter ===========
3
+ function animateValue(obj, start, end, duration) {
4
+ let startTimestamp = null;
5
+ const isPct = typeof end === 'string' && end.includes('%');
6
+ const endNum = isPct ? parseFloat(end) : end;
7
+ const startNum = parseFloat(start) || 0;
8
+
9
+ const step = (timestamp) => {
10
+ if (!startTimestamp) startTimestamp = timestamp;
11
+ const progress = Math.min((timestamp - startTimestamp) / duration, 1);
12
+ const ease = progress * (2 - progress);
13
+ const current = ease * (endNum - startNum) + startNum;
14
+
15
+ if (isPct) {
16
+ obj.innerText = current.toFixed(1) + '%';
17
+ } else {
18
+ obj.innerText = Math.floor(current);
19
+ }
20
+
21
+ if (progress < 1) {
22
+ window.requestAnimationFrame(step);
23
+ } else {
24
+ obj.innerText = end;
25
+ }
26
+ };
27
+ window.requestAnimationFrame(step);
28
+ }
29
+
30
+ // =========== Tooltip ===========
31
+ // Position and toggle tooltip visibility
32
+ document.addEventListener('mouseover', e => {
33
+ const wrap = e.target.closest('.info-wrap');
34
+ if (!wrap) return;
35
+ const tip = wrap.querySelector('.info-tip');
36
+ if (!tip) return;
37
+
38
+ tip.style.display = 'block';
39
+ const rect = wrap.getBoundingClientRect();
40
+ const tipH = tip.offsetHeight || 60;
41
+ if (rect.bottom + tipH + 10 > window.innerHeight) {
42
+ tip.style.top = (rect.top - tipH - 6) + 'px';
43
+ } else {
44
+ tip.style.top = (rect.bottom + 6) + 'px';
45
+ }
46
+ tip.style.left = Math.min(rect.left, window.innerWidth - 300) + 'px';
47
+ });
48
+
49
+ document.addEventListener('mouseout', e => {
50
+ const wrap = e.target.closest('.info-wrap');
51
+ if (!wrap) return;
52
+ const tip = wrap.querySelector('.info-tip');
53
+ if (tip) tip.style.display = 'none';
54
+ });
55
+
56
+ document.addEventListener('click', e => {
57
+ const btn = e.target.closest('.info-btn');
58
+ if (!btn) return;
59
+ const wrap = btn.closest('.info-wrap');
60
+ if (!wrap) return;
61
+ const tip = wrap.querySelector('.info-tip');
62
+ if (!tip) return;
63
+
64
+ const isVisible = tip.style.display === 'block';
65
+ tip.style.display = isVisible ? 'none' : 'block';
66
+
67
+ if (!isVisible) {
68
+ const rect = wrap.getBoundingClientRect();
69
+ const tipH = tip.offsetHeight || 60;
70
+ if (rect.bottom + tipH + 10 > window.innerHeight) {
71
+ tip.style.top = (rect.top - tipH - 6) + 'px';
72
+ } else {
73
+ tip.style.top = (rect.bottom + 6) + 'px';
74
+ }
75
+ tip.style.left = Math.min(rect.left, window.innerWidth - 300) + 'px';
76
+ }
77
+ });
78
+
79
+ // =========== Tab switching ===========
80
+ function switchTab(tab) {
81
+ ['about', 'overview', 'run-details', 'reports', 'settings', 'feedback'].forEach(t => {
82
+ const el = document.getElementById('tab-' + t);
83
+ const nav = document.getElementById('nav-' + t);
84
+ if (el) el.classList.toggle('hidden', tab !== t);
85
+ if (nav) {
86
+ if (tab === t) {
87
+ nav.classList.add('nav-item-active');
88
+ nav.classList.remove('nav-item-inactive');
89
+ } else {
90
+ nav.classList.remove('nav-item-active');
91
+ nav.classList.add('nav-item-inactive');
92
+ }
93
+ }
94
+ });
95
+ }
96
+
97
+ // =========== Toast System ===========
98
+ function showToast(message, type) {
99
+ type = type || 'info';
100
+ const icons = { success: 'fa-check-circle', error: 'fa-circle-xmark', info: 'fa-circle-info' };
101
+ const el = document.createElement('div');
102
+ el.className = `toast toast-${type}`;
103
+ el.innerHTML = `<i class="fa-solid ${icons[type] || icons.info}"></i> ${message}`;
104
+ document.getElementById('toast-container').appendChild(el);
105
+ setTimeout(() => { el.classList.add('toast-out'); setTimeout(() => el.remove(), 300); }, 3000);
106
+ }
107
+
108
+ // =========== Keyboard Shortcuts ===========
109
+ const TAB_KEYS = { '1': 'about', '2': 'overview', '3': 'run-details', '4': 'reports', '5': 'settings', '6': 'feedback' };
110
+ document.addEventListener('keydown', function(e) {
111
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') return;
112
+ if (TAB_KEYS[e.key]) { switchTab(TAB_KEYS[e.key]); return; }
113
+ if (e.key === 'd' || e.key === 'D') {
114
+ const vid = document.body.dataset.lastVideoId;
115
+ if (vid) {
116
+ window.open(`/bundle/${vid}`, '_blank');
117
+ showToast('Download started', 'success');
118
+ }
119
+ }
120
+ });
121
+
122
+ // =========== Feedback ===========
123
+ let _fbRating = 0;
124
+ let _fbEmojis = {
125
+ 'fb-recommend': null,
126
+ 'fb-security': null,
127
+ 'fb-integration': null,
128
+ 'fb-ease': null
129
+ };
130
+
131
+ function setRating(n) {
132
+ _fbRating = n;
133
+ document.querySelectorAll('.fb-star').forEach(s => {
134
+ s.classList.toggle('active', parseInt(s.dataset.v) <= n);
135
+ });
136
+ }
137
+
138
+ function setEmoji(el, qId, val) {
139
+ _fbEmojis[qId] = val;
140
+ const container = document.getElementById(qId);
141
+ if (container) {
142
+ container.querySelectorAll('.fb-emoji-btn').forEach(btn => btn.classList.remove('active'));
143
+ }
144
+ el.classList.add('active');
145
+ }
146
+
147
+ async function submitFeedback() {
148
+ const type = document.getElementById('fb-type').value;
149
+ const text = document.getElementById('fb-text').value.trim();
150
+ const usecase = document.getElementById('fb-usecase').value;
151
+
152
+ const priorities = [];
153
+ document.querySelectorAll('#fb-priorities .fb-chip.active').forEach(c => {
154
+ priorities.push(c.getAttribute('data-val'));
155
+ });
156
+
157
+ if (!text && _fbRating === 0 && !_fbEmojis['fb-recommend'] && !_fbEmojis['fb-security'] && !_fbEmojis['fb-integration'] && !_fbEmojis['fb-ease']) {
158
+ showToast("Please provide a rating, some emojis, or word feedback", "error");
159
+ return;
160
+ }
161
+
162
+ const payload = {
163
+ rating: _fbRating,
164
+ emojis: _fbEmojis,
165
+ type: type,
166
+ usecase: usecase,
167
+ priorities: priorities,
168
+ details: text,
169
+ timestamp: new Date().toISOString()
170
+ };
171
+ const res = await fetch('/api/feedback', {
172
+ method: 'POST',
173
+ headers: { 'Content-Type': 'application/json' },
174
+ body: JSON.stringify(payload)
175
+ });
176
+ if (res.ok) {
177
+ showToast('Thank you for your feedback!', 'success');
178
+ document.getElementById('fb-text').value = '';
179
+ document.querySelectorAll('#fb-priorities .fb-chip').forEach(c => c.classList.remove('active'));
180
+
181
+ // Reset Emojis
182
+ Object.keys(_fbEmojis).forEach(k => {
183
+ _fbEmojis[k] = null;
184
+ const c = document.getElementById(k);
185
+ if (c) c.querySelectorAll('.fb-emoji-btn').forEach(b => b.classList.remove('active'));
186
+ });
187
+
188
+ _fbRating = 0;
189
+ setRating(0);
190
+ } else {
191
+ showToast('Failed to submit — please try again', 'error');
192
+ }
193
+ }
194
+
195
+ // =========== PCU Calculation (client-side mirror) ===========
196
+ 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};
197
+ function calcPCU(classIn, classOut) {
198
+ let total = 0;
199
+ for (const [k, v] of Object.entries(classIn)) total += (PCU_TABLE[parseInt(k)] || 1) * v;
200
+ for (const [k, v] of Object.entries(classOut)) total += (PCU_TABLE[parseInt(k)] || 1) * v;
201
+ return Math.round(total * 10) / 10;
202
+ }
203
+
204
+ // =========== Insights Rendering ===========
205
+ function renderInsights(d) {
206
+ const panel = document.getElementById('insights-panel');
207
+ panel.classList.remove('hidden');
208
+
209
+ // Speed distribution bars
210
+ const dist = d.speed_distribution || {};
211
+ const bars = document.getElementById('speed-bars');
212
+ const colors = { slow: '#ef4444', normal: '#eab308', fast: '#22c55e' };
213
+ const labels = { slow: 'Slow', normal: 'Normal', fast: 'Fast' };
214
+ bars.innerHTML = ['slow', 'normal', 'fast'].map(cat => {
215
+ const pct = dist[cat] || 0;
216
+ const h = Math.max(8, pct * 1.2);
217
+ return `<div class="flex flex-col items-center gap-1">
218
+ <span class="text-[10px] font-bold" style="color:${colors[cat]}">${pct}%</span>
219
+ <div style="width:36px;height:${h}px;background:${colors[cat]};border-radius:6px;transition:height 0.5s"></div>
220
+ <span class="text-[9px] font-bold text-slate-500 uppercase">${labels[cat]}</span>
221
+ </div>`;
222
+ }).join('');
223
+
224
+ // Congestion insights
225
+ const ci = document.getElementById('congestion-insights');
226
+ const pcu = d.pcu || {};
227
+ ci.innerHTML = [
228
+ infoRow('Total PCU', pcu.total_pcu || 0, 'Passenger Car Units (IRC:106-1990). Normalizes mixed traffic.'),
229
+ infoRow('PCU In / Out', `${pcu.pcu_in || 0} / ${pcu.pcu_out || 0}`, 'Directional PCU split.'),
230
+ infoRow('Speed Profile', `${dist.slow || 0}% slow · ${dist.normal || 0}% normal · ${dist.fast || 0}% fast`, 'Relative speed categories within this video.'),
231
+ ].join('');
232
+
233
+ // ---- Also populate Stats tab cards ----
234
+ // Speed card in Stats
235
+ const speedCard = document.getElementById('speed-stats-card');
236
+ if (speedCard) {
237
+ speedCard.innerHTML = `<div class="flex gap-5 items-end justify-center w-full">
238
+ ${['slow', 'normal', 'fast'].map(cat => {
239
+ const pct = dist[cat] || 0;
240
+ const h = Math.max(12, pct * 1.0);
241
+ return `<div class="flex flex-col items-center gap-1.5 flex-1">
242
+ <span class="text-lg font-black" style="color:${colors[cat]}">${pct}%</span>
243
+ <div style="width:100%;max-width:48px;height:${h}px;background:${colors[cat]};border-radius:8px;transition:height 0.5s"></div>
244
+ <span class="text-[10px] font-bold text-slate-500 uppercase">${labels[cat]}</span>
245
+ </div>`;
246
+ }).join('')}
247
+ </div>`;
248
+ }
249
+
250
+ // PCU card in Stats
251
+ const pcuCard = document.getElementById('pcu-stats-card');
252
+ if (pcuCard) {
253
+ pcuCard.innerHTML = `<div class="space-y-3 w-full">
254
+ <div class="flex justify-between items-center">
255
+ <span class="text-xs font-medium text-slate-500">Total PCU</span>
256
+ <span class="text-2xl font-black" style="color:#8b5e3c">${pcu.total_pcu || 0}</span>
257
+ </div>
258
+ <div class="flex gap-3">
259
+ <div class="flex-1 bg-green-50 rounded-lg p-2.5 text-center border border-green-100">
260
+ <div class="text-lg font-bold text-green-700">${pcu.pcu_in || 0}</div>
261
+ <div class="text-[9px] font-bold text-green-500 uppercase">PCU In</div>
262
+ </div>
263
+ <div class="flex-1 bg-red-50 rounded-lg p-2.5 text-center border border-red-100">
264
+ <div class="text-lg font-bold text-red-700">${pcu.pcu_out || 0}</div>
265
+ <div class="text-[9px] font-bold text-red-500 uppercase">PCU Out</div>
266
+ </div>
267
+ </div>
268
+ </div>`;
269
+ }
270
+ }
271
+
272
+ // =========== Run Details helpers ===========
273
+ function detailRow(label, value, extra) {
274
+ extra = extra || '';
275
+ return `<div class="flex justify-between items-center border-b border-slate-800 pb-2">
276
+ <span class="text-xs font-medium text-slate-500 mono-font">${label}</span>
277
+ <span class="text-sm font-bold text-white">${value}${extra}</span>
278
+ </div>`;
279
+ }
280
+
281
+ function infoRow(label, value, tip, extra) {
282
+ extra = extra || '';
283
+ return `<div class="flex justify-between items-center border-b border-slate-800 pb-2 relative">
284
+ <span class="text-xs font-medium text-slate-500 mono-font flex items-center">${label}
285
+ <span class="info-wrap"><span class="info-btn"><i class="fa-solid fa-info"></i></span>
286
+ <span class="info-tip">${tip}</span></span>
287
+ </span>
288
+ <span class="text-sm font-bold text-white">${value}${extra}</span>
289
+ </div>`;
290
+ }
291
+
292
+ function boolBadge(val) {
293
+ if (val) return `<span class="inline-flex items-center bg-green-50 text-green-700 text-[10px] font-bold px-2 py-0.5 rounded border border-green-200"><i class="fa-solid fa-check mr-1"></i>TRUE</span>`;
294
+ return `<span class="text-[10px] font-bold text-slate-300">FALSE</span>`;
295
+ }
296
+
297
+ function populateRunDetails(c) {
298
+ const res = c.resolution || [0, 0];
299
+
300
+ document.getElementById('panel-video').innerHTML =
301
+ detailRow('video_fps', c.video_fps) +
302
+ detailRow('frames', c.frames) +
303
+ detailRow('duration', c.duration + ' sec') +
304
+ detailRow('resolution', res[0] + ' <span class="text-slate-400 text-xs">x</span> ' + res[1]) +
305
+ detailRow('pixels', (c.pixels || 0).toLocaleString());
306
+
307
+ const cpuPct = Math.min(100, Math.round((c.cpu_score / 10) * 100));
308
+ document.getElementById('panel-perf').innerHTML =
309
+ `<div class="flex justify-between items-center border-b border-slate-800 pb-2 relative">
310
+ <span class="text-xs font-medium text-slate-500 mono-font flex items-center">cpu_score
311
+ <span class="info-wrap"><span class="info-btn"><i class="fa-solid fa-info"></i></span>
312
+ <span class="info-tip">Available CPU core count used for throughput estimation.</span></span>
313
+ </span>
314
+ <div class="flex items-center">
315
+ <span class="text-sm font-bold text-white mr-2">${c.cpu_score}</span>
316
+ <div class="w-16 h-1.5 bg-slate-800 rounded-full overflow-hidden">
317
+ <div class="h-full bg-emerald-500" style="width:${cpuPct}%"></div>
318
+ </div>
319
+ </div>
320
+ </div>` +
321
+ infoRow('model_fps_est', c.model_fps_est, 'Estimated model inference throughput based on CPU benchmark.', ' <span class="text-xs text-slate-400 font-normal">fps</span>') +
322
+ infoRow('effective_fps', c.effective_fps_est, 'Adjusted throughput accounting for frame stride.', ' <span class="text-xs text-slate-400 font-normal">fps</span>');
323
+
324
+ document.getElementById('panel-model').innerHTML =
325
+ `<div class="flex justify-between items-center border-b border-slate-800 pb-2">
326
+ <span class="text-xs font-medium text-slate-500 mono-font">model</span>
327
+ <a href="https://huggingface.co/Perception365/VehicleNet-Y26s" target="_blank" class="text-sm font-bold text-white mono-font hover:text-slate-300 transition underline underline-offset-4 decoration-slate-700">Perception365/VehicleNet-Y26s</a>
328
+ </div>` +
329
+ detailRow('task', 'detect') +
330
+ detailRow('format', 'OpenVINO') +
331
+ detailRow('tracker', 'ByteTrack');
332
+
333
+ populateInferPanel(c);
334
+ }
335
+
336
+ function populateInferPanel(c) {
337
+ document.getElementById('panel-infer').innerHTML =
338
+ infoRow('imgsz', c.imgsz, 'Input image resolution for model inference.') +
339
+ infoRow('vid_stride', c.detect_stride, 'Frames skipped between consecutive detections relative to the spatial boundary.') +
340
+ infoRow('conf', (c.conf || 0.12).toFixed(2), 'Minimum confidence threshold for valid detections.') +
341
+ infoRow('iou', (c.iou || 0.60).toFixed(2), 'Intersection-over-Union threshold for non-max suppression.') +
342
+ infoRow('stream', 'TRUE', 'Frame-by-frame processing for constant memory usage.') +
343
+ infoRow('verbose', 'FALSE', 'Console logging suppressed during inference.');
344
+ }
345
+
346
+ // =========== Palettes ===========
347
+ const PALETTES = {
348
+ default: { congestion: '#f97316', congestionBg: 'rgba(249,115,22,0.08)', dominance: '#14b8a6', flow: '#3b82f6', doughIn: '#3b82f6', doughOut: '#f97316' },
349
+ vibrant: { congestion: '#ff2d55', congestionBg: 'rgba(255,45,85,0.08)', dominance: '#a855f7', flow: '#22d3ee', doughIn: '#22d3ee', doughOut: '#ff2d55' },
350
+ corporate: { congestion: '#1e40af', congestionBg: 'rgba(30,64,175,0.08)', dominance: '#0d9488', flow: '#475569', doughIn: '#475569', doughOut: '#1e40af' },
351
+ neon: { congestion: '#f0abfc', congestionBg: 'rgba(240,171,252,0.08)', dominance: '#22d3ee', flow: '#a855f7', doughIn: '#a855f7', doughOut: '#f0abfc' },
352
+ earth: { congestion: '#84cc16', congestionBg: 'rgba(132,204,22,0.08)', dominance: '#22c55e', flow: '#f59e0b', doughIn: '#f59e0b', doughOut: '#84cc16' },
353
+ ocean: { congestion: '#06b6d4', congestionBg: 'rgba(6,182,212,0.08)', dominance: '#3b82f6', flow: '#14b8a6', doughIn: '#14b8a6', doughOut: '#06b6d4' },
354
+ sunset: { congestion: '#f59e0b', congestionBg: 'rgba(245,158,11,0.08)', dominance: '#f43f5e', flow: '#fb923c', doughIn: '#fb923c', doughOut: '#f59e0b' },
355
+ midnight: { congestion: '#38bdf8', congestionBg: 'rgba(56,189,248,0.08)', dominance: '#1d4ed8', flow: '#f8fafc', doughIn: '#f8fafc', doughOut: '#38bdf8' },
356
+ gold: { congestion: '#fbbf24', congestionBg: 'rgba(251,191,36,0.08)', dominance: '#a8a29e', flow: '#f5f5f4', doughIn: '#f5f5f4', doughOut: '#fbbf24' }
357
+ };
358
+
359
+ // Read settings from sessionStorage (set by settings.html)
360
+ const rawRun = sessionStorage.getItem('funky_run');
361
+ const runSettings = rawRun ? (JSON.parse(rawRun).settings || {}) : {};
362
+ let currentPalette = runSettings.palette || 'default';
363
+ let activePalette = PALETTES[currentPalette];
364
+
365
+ // =========== Charts ===========
366
+ Chart.defaults.font.family = "'Montserrat', sans-serif";
367
+ Chart.defaults.color = '#888888';
368
+ Chart.defaults.borderColor = '#222222';
369
+ Chart.defaults.plugins.tooltip.backgroundColor = '#0a0a0a';
370
+ Chart.defaults.plugins.tooltip.titleColor = '#ffffff';
371
+ Chart.defaults.plugins.tooltip.bodyColor = '#aaaaaa';
372
+ Chart.defaults.plugins.tooltip.borderColor = '#222222';
373
+ Chart.defaults.plugins.tooltip.borderWidth = 1;
374
+
375
+ let MODEL_CLASSES = {};
376
+ let BUSINESS_MAP = {};
377
+
378
+ const congChart = new Chart(document.getElementById('congestionChart').getContext('2d'), {
379
+ type: 'line',
380
+ data: {
381
+ labels: [], datasets: [{
382
+ data: [],
383
+ borderColor: activePalette.congestion,
384
+ backgroundColor: activePalette.congestionBg,
385
+ fill: true,
386
+ tension: 0.2,
387
+ borderWidth: 1.5,
388
+ pointRadius: 0
389
+ }]
390
+ },
391
+ options: {
392
+ responsive: true, maintainAspectRatio: false,
393
+ plugins: { legend: { display: false } },
394
+ scales: {
395
+ x: { grid: { display: false }, ticks: { maxTicksLimit: 10, font: { size: 10 }, color: '#666666' }, title: { display: true, text: 'Frame Index', font: { size: 10, weight: '700' }, color: '#888888' } },
396
+ y: { grid: { color: '#1a1a1a' }, beginAtZero: true, ticks: { font: { size: 10 }, color: '#666666' }, title: { display: true, text: 'Active Vehicles', font: { size: 10, weight: '700' }, color: '#888888' } }
397
+ },
398
+ animation: { duration: 0 }
399
+ },
400
+ plugins: []
401
+ });
402
+
403
+ const doughChart = new Chart(document.getElementById('doughnutChart').getContext('2d'), {
404
+ type: 'doughnut',
405
+ data: {
406
+ labels: ['Incoming', 'Outgoing'], datasets: [{
407
+ data: [0, 0],
408
+ backgroundColor: [activePalette.doughIn, activePalette.doughOut],
409
+ borderColor: '#0a0a0a',
410
+ borderWidth: 3,
411
+ hoverOffset: 6
412
+ }]
413
+ },
414
+ options: {
415
+ responsive: true, maintainAspectRatio: false,
416
+ cutout: '68%',
417
+ plugins: {
418
+ legend: { display: true, position: 'bottom', labels: { padding: 12, usePointStyle: true, pointStyle: 'circle', font: { size: 10, weight: '600' } } }
419
+ },
420
+ animation: { duration: 0 }
421
+ },
422
+ plugins: []
423
+ });
424
+
425
+ const domChart = new Chart(document.getElementById('dominanceChart').getContext('2d'), {
426
+ type: 'bar',
427
+ data: { labels: [], datasets: [{ data: [], backgroundColor: activePalette.dominance, borderRadius: 2 }] },
428
+ options: {
429
+ responsive: true, maintainAspectRatio: false,
430
+ plugins: { legend: { display: false } },
431
+ scales: {
432
+ x: { grid: { display: false }, ticks: { font: { size: 10, weight: '500' }, color: '#666666' } },
433
+ y: { grid: { color: '#1a1a1a' }, beginAtZero: true, ticks: { font: { size: 10 }, color: '#666666' }, title: { display: true, text: 'Total Vehicle Count', font: { size: 10, weight: '700' }, color: '#888888' } }
434
+ },
435
+ animation: { duration: 0 }
436
+ },
437
+ plugins: []
438
+ });
439
+
440
+ const flowChart = new Chart(document.getElementById('flowChart').getContext('2d'), {
441
+ type: 'bar',
442
+ data: { labels: [], datasets: [{ data: [], backgroundColor: activePalette.flow, borderColor: '#0a0a0a', borderWidth: 1.5, barPercentage: 1.0, categoryPercentage: 1.0 }] },
443
+ options: {
444
+ responsive: true, maintainAspectRatio: false,
445
+ plugins: { legend: { display: false } },
446
+ scales: {
447
+ x: { grid: { display: false }, ticks: { maxTicksLimit: 10, font: { size: 10 }, color: '#666666' }, title: { display: true, text: 'Time (seconds)', font: { size: 10, weight: '700' }, color: '#888888' } },
448
+ y: { grid: { color: '#1a1a1a' }, beginAtZero: true, ticks: { font: { size: 10 }, color: '#666666' }, title: { display: true, text: 'Vehicles Crossed', font: { size: 10, weight: '700' }, color: '#888888' } }
449
+ },
450
+ animation: { duration: 0 }
451
+ },
452
+ plugins: []
453
+ });
454
+
455
+ // =========== Update functions ===========
456
+ function sumValues(obj) { return Object.values(obj).reduce((a, b) => a + b, 0); }
457
+
458
+ // =========== Live Palette Switching ===========
459
+ function applyPalette(key) {
460
+ activePalette = PALETTES[key] || PALETTES.default;
461
+ currentPalette = key;
462
+
463
+ // Congestion
464
+ congChart.data.datasets[0].borderColor = activePalette.congestion;
465
+ congChart.data.datasets[0].backgroundColor = activePalette.congestionBg;
466
+ congChart.update();
467
+
468
+ // Doughnut
469
+ doughChart.data.datasets[0].backgroundColor = [activePalette.doughIn, activePalette.doughOut];
470
+ doughChart.update();
471
+
472
+ // Dominance
473
+ domChart.data.datasets[0].backgroundColor = activePalette.dominance;
474
+ domChart.update();
475
+
476
+ // Flow
477
+ flowChart.data.datasets[0].backgroundColor = activePalette.flow;
478
+ flowChart.update();
479
+
480
+ // Update palette dropdown in progress bar
481
+ const barSel = document.getElementById('live-palette-select');
482
+ if (barSel) barSel.value = key;
483
+ }
484
+
485
+ function renderPalettePreview(key) {
486
+ const pal = PALETTES[key];
487
+ const colors = [
488
+ { color: pal.congestion, label: 'Congestion' },
489
+ { color: pal.dominance, label: 'Dominance' },
490
+ { color: pal.flow, label: 'Flow' },
491
+ { color: pal.doughIn, label: 'Incoming' },
492
+ { color: pal.doughOut, label: 'Outgoing' }
493
+ ];
494
+ const el = document.getElementById('live-palette-preview');
495
+ if (el) {
496
+ el.innerHTML = colors.map(c =>
497
+ `<div class="flex-1 rounded-lg overflow-hidden border border-neutral-800">
498
+ <div class="h-6" style="background:${c.color}"></div>
499
+ <div class="text-[8px] font-bold text-neutral-500 text-center py-1 bg-neutral-900">${c.label}</div>
500
+ </div>`
501
+ ).join('');
502
+ }
503
+ }
504
+
505
+ function populateSettingsTab(config, settings) {
506
+ // Populate stepper values from config
507
+ document.getElementById('sv-imgsz').textContent = config.imgsz || 640;
508
+ document.getElementById('sv-conf').textContent = (config.conf || 0.12).toFixed(2);
509
+ document.getElementById('sv-iou').textContent = (config.iou || 0.60).toFixed(2);
510
+ document.getElementById('sv-stride').textContent = config.detect_stride || 2;
511
+
512
+ // Populate export settings
513
+ const selReport = document.getElementById('sv-report');
514
+ if (selReport) selReport.value = settings.reportFormat || 'png';
515
+ const togAnnot = document.getElementById('sv-annotated');
516
+ if (togAnnot && settings.annotatedVideo) togAnnot.classList.add('active');
517
+
518
+ // Set live palette dropdown
519
+ const sel = document.getElementById('live-palette-select');
520
+ if (sel) sel.value = currentPalette;
521
+ renderPalettePreview(currentPalette);
522
+ }
523
+
524
+ // =========== Settings Stepper Logic ===========
525
+ const PARAM_LIMITS = {
526
+ imgsz: { min: 640, max: 1280 },
527
+ conf: { min: 0.10, max: 0.95 },
528
+ iou: { min: 0.50, max: 0.95 },
529
+ stride: { min: 1, max: 10 },
530
+ smoothing: { min: 0.05, max: 0.95 }
531
+ };
532
+
533
+ function stepParam(param, delta) {
534
+ const el = document.getElementById('sv-' + param);
535
+ if (!el) return;
536
+ const limits = PARAM_LIMITS[param];
537
+ let val = parseFloat(el.textContent);
538
+ val = Math.round((val + delta) * 100) / 100;
539
+ val = Math.max(limits.min, Math.min(limits.max, val));
540
+ el.textContent = (param === 'conf' || param === 'iou' || param === 'smoothing') ? val.toFixed(2) : val;
541
+ }
542
+
543
+ function lockSettings() {
544
+ document.querySelectorAll('#settings-params .s-row').forEach(row => {
545
+ const p = row.dataset.param;
546
+ if (p && p !== 'palette') {
547
+ row.classList.add('disabled');
548
+ }
549
+ });
550
+ const reportRow = document.getElementById('sv-report');
551
+ if (reportRow) reportRow.closest('.s-row').classList.add('disabled');
552
+ const annotatedRow = document.getElementById('sv-annotated');
553
+ if (annotatedRow) annotatedRow.closest('.s-row').classList.add('disabled');
554
+ const wrap = document.getElementById('settings-start-wrap');
555
+ if (wrap) wrap.style.display = 'none';
556
+ }
557
+
558
+ function startNewAnalysis() {
559
+ sessionStorage.clear();
560
+ _params = null;
561
+ [congChart, doughChart, domChart, flowChart].forEach(c => {
562
+ c.data.labels = [];
563
+ c.data.datasets[0].data = [];
564
+ c.update();
565
+ });
566
+ window.location.href = '/';
567
+ }
568
+ function updateBreakdown(classIn, classOut) {
569
+ const container = document.getElementById('class-breakdown');
570
+ const totalAll = sumValues(classIn) + sumValues(classOut);
571
+ container.innerHTML = '';
572
+
573
+ // Update Doughnut border logic (remove gap if unidirectional)
574
+ const sumIn = sumValues(classIn);
575
+ const sumOut = sumValues(classOut);
576
+ doughChart.data.datasets[0].borderWidth = (sumIn === 0 || sumOut === 0) ? 0 : 3;
577
+ doughChart.data.datasets[0].data = [sumIn, sumOut];
578
+ doughChart.update();
579
+ document.getElementById('cnt-total').innerText = totalAll;
580
+
581
+ Object.keys(MODEL_CLASSES).map(Number).sort((a, b) => a - b).forEach(id => {
582
+ const inC = classIn[String(id)] || 0;
583
+ const outC = classOut[String(id)] || 0;
584
+ const total = inC + outC;
585
+ const pct = totalAll > 0 ? ((total / totalAll) * 100).toFixed(1) : '0.0';
586
+
587
+ const row = document.createElement('div');
588
+ row.className = 'flex items-center justify-between text-xs py-2 border-b border-slate-800';
589
+ row.innerHTML = `
590
+ <div class="w-[30%] font-bold text-white truncate" title="${MODEL_CLASSES[id]}">${MODEL_CLASSES[id]}</div>
591
+ <div class="w-[20%] text-slate-500 text-[11px]">${total} total</div>
592
+ <div class="w-[15%] text-slate-500 text-[11px]"><i class="fa-solid fa-arrow-down text-[9px] mr-1"></i>${inC}</div>
593
+ <div class="w-[15%] text-slate-500 text-[11px]"><i class="fa-solid fa-arrow-up text-[9px] mr-1"></i>${outC}</div>
594
+ <div class="w-[20%] text-right font-bold text-white">${pct}%</div>
595
+ `;
596
+ container.appendChild(row);
597
+ });
598
+ }
599
+
600
+ function updateDominance(classIn, classOut) {
601
+ const labels = [], values = [];
602
+ for (const [group, ids] of Object.entries(BUSINESS_MAP)) {
603
+ let total = 0;
604
+ ids.forEach(id => { total += (classIn[String(id)] || 0) + (classOut[String(id)] || 0); });
605
+ labels.push(group);
606
+ values.push(total);
607
+ }
608
+ domChart.data.labels = labels;
609
+ domChart.data.datasets[0].data = values;
610
+ domChart.update();
611
+ }
612
+
613
+ function buildFlowHistogram(flowTimes, videoDuration) {
614
+ const binCount = Math.max(1, Math.ceil(videoDuration));
615
+ const bins = new Array(binCount).fill(0);
616
+ const labels = [];
617
+ for (let i = 0; i < binCount; i++) labels.push(i);
618
+ flowTimes.forEach(t => { bins[Math.min(Math.floor(t), binCount - 1)]++; });
619
+ flowChart.data.labels = labels;
620
+ flowChart.data.datasets[0].data = bins;
621
+ flowChart.update();
622
+ }
623
+
624
+ let _alpha = 0.25;
625
+
626
+ function updateCongestion(congestion, stride) {
627
+ let data = congestion;
628
+ // Apply EMA smoothing if alpha is less than 1 (1 = no smoothing)
629
+ if (_alpha < 0.99) {
630
+ data = [];
631
+ let s = congestion[0] || 0;
632
+ for (let v of congestion) {
633
+ s = _alpha * v + (1 - _alpha) * s;
634
+ data.push(s);
635
+ }
636
+ }
637
+
638
+ const len = data.length;
639
+ if (len <= 200) {
640
+ congChart.data.labels = data.map((_, i) => i * stride);
641
+ congChart.data.datasets[0].data = data;
642
+ } else {
643
+ // Dynamic sampling to keep chart performance high
644
+ const step = Math.ceil(len / 200);
645
+ const sampled = [], labels = [];
646
+ for (let i = 0; i < len; i += step) {
647
+ labels.push(i * stride);
648
+ sampled.push(data[i]);
649
+ }
650
+ congChart.data.labels = labels;
651
+ congChart.data.datasets[0].data = sampled;
652
+ }
653
+ congChart.update();
654
+ }
655
+
656
+ // =========== Main ===========
657
+ let _params = null;
658
+
659
+ async function init() {
660
+ const raw = sessionStorage.getItem('funky_run');
661
+ if (!raw) { window.location.href = '/'; return; }
662
+
663
+ _params = JSON.parse(raw);
664
+
665
+ // SECURITY: Clear session storage so refresh always redirects home
666
+ sessionStorage.removeItem('funky_run');
667
+
668
+ const cRes = await fetch('/constants');
669
+ const cData = await cRes.json();
670
+ MODEL_CLASSES = cData.classes;
671
+ BUSINESS_MAP = cData.business_map;
672
+
673
+ populateAndInit(_params);
674
+
675
+ // SECURITY: Clear session storage after populate so refresh triggers redirect
676
+ sessionStorage.removeItem('funky_run');
677
+
678
+ // Show Settings tab first, but also initialized with About implicitly in sidebar hierarchy
679
+ switchTab('settings');
680
+ }
681
+
682
+ function populateAndInit(params) {
683
+ populateRunDetails(params.config);
684
+ populateSettingsTab(params.config, params.settings || {});
685
+ }
686
+
687
+ function startProcessingFromSettings() {
688
+ if (!_params) return;
689
+
690
+ // Read current stepper/control values
691
+ const imgsz = parseInt(document.getElementById('sv-imgsz').textContent);
692
+ const conf = parseFloat(document.getElementById('sv-conf').textContent);
693
+ const iou = parseFloat(document.getElementById('sv-iou').textContent);
694
+ const stride = parseInt(document.getElementById('sv-stride').textContent);
695
+ const reportFmt = document.getElementById('sv-report').value;
696
+ const annotated = document.getElementById('sv-annotated').classList.contains('active');
697
+ _alpha = parseFloat(document.getElementById('sv-smoothing').textContent) || 0.25;
698
+
699
+ // Annotation Options
700
+ const annotated_options = {
701
+ bbox: true, // Always true if export is enabled
702
+ spatial: document.getElementById('chip-spatial').classList.contains('active'),
703
+ class_name: document.getElementById('chip-class_name').classList.contains('active'),
704
+ class_id: document.getElementById('chip-class_id').classList.contains('active'),
705
+ track_id: document.getElementById('chip-track_id').classList.contains('active')
706
+ };
707
+
708
+ const exportJson = document.getElementById('sv-export-json').classList.contains('active');
709
+ const exportCsv = document.getElementById('sv-export-csv').classList.contains('active');
710
+
711
+ // Apply to config
712
+ _params.config.imgsz = imgsz;
713
+ _params.config.conf = conf;
714
+ _params.config.iou = iou;
715
+ _params.config.detect_stride = stride;
716
+
717
+ // Reflect final resolved params in Run tab
718
+ populateInferPanel(_params.config);
719
+
720
+ // Lock settings
721
+ lockSettings();
722
+
723
+ // Switch to overview
724
+ switchTab('overview');
725
+ document.getElementById('proc-label').innerText = 'Processing';
726
+
727
+ // Reset Run Tab Results to Awaiting
728
+ const analyzeAgainBtn = document.getElementById('run-analyze-again-btn');
729
+ if (analyzeAgainBtn) {
730
+ analyzeAgainBtn.classList.add('hidden');
731
+ }
732
+
733
+ const badge = document.getElementById('results-status-badge');
734
+ if (badge) {
735
+ badge.innerText = 'Processing';
736
+ badge.className = 'px-2.5 py-1 bg-slate-800 text-white text-[10px] font-bold rounded-full uppercase tracking-tighter animate-pulse';
737
+ }
738
+ document.getElementById('run-results-content').innerHTML = `
739
+ <div class="flex flex-col items-center justify-center p-8 bg-black/40 border border-slate-800 rounded-2xl col-span-3 text-slate-500">
740
+ <i class="fa-solid fa-spinner fa-spin text-2xl mb-3 text-white"></i>
741
+ <span class="text-xs font-semibold">Executing inference pipeline... results pending</span>
742
+ </div>`;
743
+
744
+ // Update Reports tab pending message
745
+ const repIcon = document.getElementById('reports-pending-icon');
746
+ if (repIcon) repIcon.className = 'fa-solid fa-circle-notch fa-spin text-[#c89a6c]';
747
+ const repText = document.getElementById('reports-pending-text');
748
+ if (repText) repText.innerText = 'Generating artifacts & rendering analytics... Please wait';
749
+
750
+
751
+ // Start WebSocket
752
+ const videoDuration = _params.config.duration || 10;
753
+
754
+ const proto = location.protocol === 'https:' ? 'wss' : 'ws';
755
+ const ws = new WebSocket(`${proto}://${location.host}/ws/run`);
756
+
757
+ ws.onopen = () => {
758
+ ws.send(JSON.stringify({
759
+ video_id: _params.video_id,
760
+ line: _params.line,
761
+ config: _params.config,
762
+ annotated_video: annotated,
763
+ annotated_options: annotated_options,
764
+ export_json: exportJson,
765
+ export_csv: exportCsv,
766
+ report_format: reportFmt
767
+ }));
768
+ };
769
+
770
+ ws.onerror = e => {
771
+ console.error('WS Error:', e);
772
+ document.getElementById('proc-label').innerText = 'Connection Error';
773
+ showToast('Connection error — server may be busy', 'error');
774
+ if (badge) {
775
+ badge.innerText = 'Pipeline Failed';
776
+ badge.className = 'px-2.5 py-1 bg-red-900/40 text-red-100 text-[10px] font-bold rounded-full uppercase tracking-tighter';
777
+ }
778
+ };
779
+
780
+ let processingDone = false;
781
+
782
+ ws.onclose = () => {
783
+ console.log('WS Closed');
784
+ if (!processingDone) {
785
+ // Closed before done=True received — show error state
786
+ document.getElementById('proc-label').innerText = 'Disconnected';
787
+ const badge = document.getElementById('results-status-badge');
788
+ if (badge) {
789
+ badge.innerText = 'Connection Lost';
790
+ badge.className = 'px-2.5 py-1 bg-red-900/40 text-red-300 text-[10px] font-bold rounded-full uppercase tracking-tighter';
791
+ }
792
+ document.getElementById('run-results-content').innerHTML = `
793
+ <div class="flex flex-col items-center justify-center p-8 border-2 border-dashed border-red-900/40 rounded-2xl col-span-3 text-slate-400">
794
+ <i class="fa-solid fa-triangle-exclamation text-2xl mb-3 text-red-400"></i>
795
+ <span class="text-xs font-semibold mb-1">Processing connection was lost.</span>
796
+ <span class="text-[10px] text-slate-500 mb-4">The server may have timed out or restarted. Please try again.</span>
797
+ <button onclick="startNewAnalysis()" class="text-[10px] font-bold uppercase tracking-widest px-4 py-2 rounded-full" style="background:#111;border:1px solid #2a2a2a;color:#c89a6c">
798
+ &larr; Start New Analysis
799
+ </button>
800
+ </div>`;
801
+ }
802
+ };
803
+
804
+ let lastUIUpdate = 0;
805
+
806
+ ws.onmessage = e => {
807
+ const d = JSON.parse(e.data);
808
+
809
+ // Hide empty state on first data
810
+ const emptyState = document.getElementById('stats-empty-state');
811
+ if (emptyState) emptyState.style.display = 'none';
812
+ if (d.error) {
813
+ processingDone = true;
814
+ document.getElementById('proc-label').innerText = 'Engine Error';
815
+ const badge = document.getElementById('results-status-badge');
816
+ if (badge) {
817
+ badge.innerText = 'Failed';
818
+ badge.className = 'px-2.5 py-1 bg-red-900/40 text-red-300 text-[10px] font-bold rounded-full uppercase tracking-tighter';
819
+ }
820
+ console.error('[UrbanFlow] Engine error:', d.detail || d.error);
821
+ document.getElementById('run-results-content').innerHTML = `
822
+ <div class="flex flex-col items-center justify-center p-8 border-2 border-dashed border-red-900/40 rounded-2xl col-span-3 text-slate-400">
823
+ <i class="fa-solid fa-triangle-exclamation text-2xl mb-3 text-red-400"></i>
824
+ <span class="text-xs font-semibold mb-1">Inference pipeline failed.</span>
825
+ <span class="text-[10px] text-slate-500 mb-4 text-center max-w-xs">${d.error}</span>
826
+ <button onclick="startNewAnalysis()" class="text-[10px] font-bold uppercase tracking-widest px-4 py-2 rounded-full" style="background:#111;border:1px solid #2a2a2a;color:#c89a6c">
827
+ &larr; Start New Analysis
828
+ </button>
829
+ </div>`;
830
+ return;
831
+ }
832
+
833
+ if (d.done) {
834
+ processingDone = true;
835
+ document.getElementById('proc-label').innerText = 'Complete';
836
+ document.getElementById('proc-bar').style.width = '100%';
837
+ document.getElementById('proc-pct').innerText = '100%';
838
+ // Force frame counter to n/n
839
+ const framesEl = document.getElementById('proc-frames');
840
+ if (framesEl) {
841
+ const parts = framesEl.innerText.split('/');
842
+ if (parts.length === 2) {
843
+ const total = parts[1].trim().replace(' Frames', '');
844
+ framesEl.innerText = `${total} / ${total} Frames`;
845
+ }
846
+ }
847
+
848
+ // Update Run Tab Badge
849
+ const badge = document.getElementById('results-status-badge');
850
+ if (badge) {
851
+ badge.innerText = 'Completed';
852
+ badge.className = 'px-2.5 py-1 bg-white text-black text-[10px] font-bold rounded-full uppercase tracking-tighter';
853
+ }
854
+
855
+ const analyzeAgainBtn = document.getElementById('run-analyze-again-btn');
856
+ if (analyzeAgainBtn) {
857
+ analyzeAgainBtn.classList.remove('hidden');
858
+ }
859
+
860
+ document.getElementById('run-results-content').innerHTML =
861
+ detailRow('Inference Time', d.processing_time + ' sec') +
862
+ infoRow('Throughput (FPS)', d.actual_fps, 'Measured frame throughput during processing.', ' <span class="text-xs text-slate-400 font-normal">fps</span>') +
863
+ infoRow('Real-time Ratio', d.speed_vs_realtime + 'x', 'Processing speed relative to video playback rate.');
864
+
865
+ if (d.video_id) {
866
+ loadReports(d.video_id).then(data => {
867
+ if (!data) return;
868
+
869
+ // Auto-Download Logic (Respects live toggle state)
870
+ if (document.getElementById('sv-auto-download').classList.contains('active')) {
871
+ // Download the full bundle ZIP via direct navigation
872
+ setTimeout(() => {
873
+ console.log('[UrbanFlow] Fetching ZIP bundle for:', d.video_id);
874
+ window.open(`/bundle/${d.video_id}`, '_blank');
875
+ }, 1000);
876
+ }
877
+ });
878
+ }
879
+
880
+ // Disable Auto-Download toggle after completion
881
+ const adToggle = document.getElementById('sv-auto-download');
882
+ if (adToggle) {
883
+ adToggle.closest('.s-row').classList.add('disabled');
884
+ }
885
+ const jsonToggle = document.getElementById('sv-export-json');
886
+ if (jsonToggle) {
887
+ jsonToggle.closest('.s-row').classList.add('disabled');
888
+ }
889
+ const csvToggle = document.getElementById('sv-export-csv');
890
+ if (csvToggle) {
891
+ csvToggle.closest('.s-row').classList.add('disabled');
892
+ }
893
+
894
+ // Show New Analysis button in Settings
895
+ const newWrap = document.getElementById('new-analysis-wrap');
896
+ if (newWrap) newWrap.classList.remove('hidden');
897
+
898
+ // Toast + Insights
899
+ showToast('Processing complete — artifacts ready', 'success');
900
+ renderInsights(d);
901
+
902
+ // Store video_id for keyboard shortcut download
903
+ document.body.setAttribute('data-last-video-id', d.video_id);
904
+
905
+ return;
906
+ }
907
+
908
+ let pct = ((d.frame_index / d.total_iters) * 100).toFixed(1);
909
+ if (d.frame_index >= d.total_iters - 1) pct = '100.0';
910
+ document.getElementById('proc-bar').style.width = pct + '%';
911
+ document.getElementById('proc-frames').innerText = `${d.frame_index} / ${d.total_iters} Frames`;
912
+
913
+ const procPctEl = document.getElementById('proc-pct');
914
+ const currPct = parseFloat(procPctEl.innerText) || 0;
915
+ animateValue(procPctEl, currPct, pct + '%', 300);
916
+
917
+ const totalIn = sumValues(d.class_in);
918
+ const totalOut = sumValues(d.class_out);
919
+
920
+ const cntTotalEl = document.getElementById('cnt-total');
921
+ const currTotal = parseInt(cntTotalEl.innerText) || 0;
922
+ animateValue(cntTotalEl, currTotal, totalIn + totalOut, 300);
923
+
924
+ // Update PCU display
925
+ const pcuVal = calcPCU(d.class_in, d.class_out);
926
+ const pcuEl = document.getElementById('cnt-pcu');
927
+ if (pcuEl) pcuEl.innerText = pcuVal;
928
+
929
+ // Update doughnut
930
+ doughChart.data.datasets[0].data = [totalIn, totalOut];
931
+ doughChart.update();
932
+
933
+ const now = performance.now();
934
+ if (now - lastUIUpdate < 300) return;
935
+ lastUIUpdate = now;
936
+
937
+ updateCongestion(d.congestion, stride);
938
+ updateBreakdown(d.class_in, d.class_out);
939
+ updateDominance(d.class_in, d.class_out);
940
+ buildFlowHistogram(d.flow_times, videoDuration);
941
+ };
942
+ }
943
+
944
+ const REPORT_LABELS = {
945
+ 'direction_pie.png': { title: 'Directional Split', desc: 'Incoming vs outgoing vehicle ratio' },
946
+ 'direction_pie.pdf': { title: 'Directional Split', desc: 'Incoming vs outgoing vehicle ratio' },
947
+ 'flow_over_time.png': { title: 'Traffic Flow', desc: 'Vehicles crossing the line over time' },
948
+ 'flow_over_time.pdf': { title: 'Traffic Flow', desc: 'Vehicles crossing the line over time' },
949
+ 'congestion_index.png': { title: 'Congestion Index', desc: 'Active vehicles per frame with smoothing' },
950
+ 'congestion_index.pdf': { title: 'Congestion Index', desc: 'Active vehicles per frame with smoothing' },
951
+ 'class_dominance.png': { title: 'Class Dominance', desc: 'Vehicle count by classification type' },
952
+ 'class_dominance.pdf': { title: 'Class Dominance', desc: 'Vehicle count by classification type' },
953
+ 'confidence_dist.png': { title: 'Confidence Curve', desc: 'Distribution of detection confidences' },
954
+ 'confidence_dist.pdf': { title: 'Confidence Curve', desc: 'Distribution of detection confidences' },
955
+ 'annotated.mp4': { title: 'Annotated Video Export', desc: 'Rendered video with tracking overlays' },
956
+ 'heatmap.png': { title: 'Spatial Density Heatmap', desc: 'Aggregated path utilization mapping' },
957
+ 'heatmap.pdf': { title: 'Spatial Density Heatmap', desc: 'Aggregated path utilization mapping' },
958
+ 'raw_data.csv': { title: 'Raw Analytics Export', desc: 'Comma-separated values of all crossings' },
959
+ 'analysis.json': { title: 'Structured JSON Export', desc: 'Complete analysis data with metadata for API consumption' }
960
+ };
961
+
962
+ async function loadReports(videoId) {
963
+ const res = await fetch(`/reports/${videoId}`, { method: 'POST' });
964
+ const data = await res.json();
965
+ if (!data.files || !data.files.length) return null;
966
+
967
+ document.getElementById('reports-pending').classList.add('hidden');
968
+ document.getElementById('reports-pending-message').classList.add('hidden');
969
+ document.getElementById('post-process-cards').classList.remove('hidden');
970
+ const grid = document.getElementById('reports-grid');
971
+ grid.classList.remove('hidden');
972
+ grid.innerHTML = '';
973
+
974
+ data.files.forEach(name => {
975
+ const info = REPORT_LABELS[name] || { title: name, desc: '' };
976
+ const url = `/reports/${videoId}/${name}`;
977
+ const isVideo = name.endsWith('.mp4');
978
+ const isPDF = name.endsWith('.pdf');
979
+ const isCSV = name.endsWith('.csv');
980
+ const card = document.createElement('div');
981
+ card.className = 'bg-black rounded-xl border border-slate-800 shadow-sm flex flex-col overflow-hidden';
982
+
983
+ let previewHTML = '';
984
+ if (isVideo) {
985
+ previewHTML = `
986
+ <div class="flex flex-col items-center justify-center py-12 text-slate-700">
987
+ <i class="fa-solid fa-film text-6xl mb-4 text-white"></i>
988
+ <span class="text-xs font-bold uppercase tracking-widest text-slate-500">Video Ready for Local Analysis</span>
989
+ </div>`;
990
+ } else if (isPDF) {
991
+ previewHTML = `
992
+ <div class="flex flex-col items-center justify-center py-12 text-slate-700">
993
+ <i class="fa-solid fa-file-pdf text-6xl mb-4 text-white"></i>
994
+ <span class="text-xs font-bold uppercase tracking-widest text-slate-500">PDF Document</span>
995
+ </div>`;
996
+ } else if (isCSV) {
997
+ previewHTML = `
998
+ <div class="flex flex-col items-center justify-center py-12 text-slate-700">
999
+ <i class="fa-solid fa-file-csv text-6xl mb-4 text-white"></i>
1000
+ <span class="text-xs font-bold uppercase tracking-widest text-slate-500">Raw Analytics Export</span>
1001
+ </div>`;
1002
+ } else if (name.endsWith('.json')) {
1003
+ previewHTML = `
1004
+ <div class="flex flex-col items-center justify-center py-12 text-slate-700">
1005
+ <i class="fa-solid fa-code text-6xl mb-4 text-white"></i>
1006
+ <span class="text-xs font-bold uppercase tracking-widest text-slate-500">Structured JSON</span>
1007
+ </div>`;
1008
+ } else {
1009
+ previewHTML = `<img src="${url}" alt="${info.title}" class="max-w-full max-h-[320px] object-contain rounded">`;
1010
+ }
1011
+
1012
+ card.innerHTML = `
1013
+ <div class="px-5 py-3 border-b border-slate-800 bg-slate-900/40 flex justify-between items-center">
1014
+ <div>
1015
+ <h3 class="font-bold text-white text-sm">${info.title}</h3>
1016
+ <p class="text-[10px] text-slate-400 mt-0.5">${info.desc}</p>
1017
+ </div>
1018
+ <a href="${url}" download="${name}"
1019
+ class="inline-flex items-center gap-1.5 px-3 py-1.5 border border-[#444444] text-white text-[10px] font-bold rounded-full hover:bg-neutral-800 transition uppercase tracking-wider">
1020
+ <i class="fa-solid fa-download text-[9px]"></i> Download
1021
+ </a>
1022
+ </div>
1023
+ <div class="p-4 flex items-center justify-center bg-black/30">
1024
+ ${previewHTML}
1025
+ </div>
1026
+ `;
1027
+ grid.appendChild(card);
1028
+ });
1029
+ return data;
1030
+ }
1031
+
1032
+ function toggleExportMaster(el) {
1033
+ el.classList.toggle('active');
1034
+ const chips = document.getElementById('chip-selector');
1035
+ if (el.classList.contains('active')) {
1036
+ chips.classList.remove('hidden-chip-container');
1037
+ } else {
1038
+ chips.classList.add('hidden-chip-container');
1039
+ }
1040
+ }
1041
+
1042
+ function toggleAutoDownload(el) {
1043
+ el.classList.toggle('active');
1044
+ }
1045
+
1046
+ function toggleChip(id) {
1047
+ const chip = document.getElementById(`chip-${id}`);
1048
+ chip.classList.toggle('active');
1049
+ const icon = chip.querySelector('i');
1050
+ if (chip.classList.contains('active')) {
1051
+ icon.className = 'fa-solid fa-check';
1052
+ } else {
1053
+ icon.className = 'fa-solid fa-plus';
1054
+ }
1055
+ }
1056
+
1057
+ init();
1058
+
frontend/run_details.html DELETED
@@ -1,8 +0,0 @@
1
- <!DOCTYPE html>
2
- <html>
3
-
4
- <head>
5
- <meta http-equiv="refresh" content="0;url=vehicles.html">
6
- </head>
7
-
8
- </html>
 
 
 
 
 
 
 
 
 
frontend/settings.html DELETED
@@ -1,433 +0,0 @@
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>UrbanFlow</title>
8
- <link rel="icon" type="image/svg+xml" href="rf.png">
9
- <script src="https://cdn.tailwindcss.com"></script>
10
- <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
11
- <link
12
- href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Montserrat:wght@400;500;600;700;800;900&display=swap"
13
- rel="stylesheet">
14
- <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet">
15
- <style>
16
- body {
17
- font-family: 'Inter', sans-serif;
18
- }
19
-
20
- .font-montserrat {
21
- font-family: 'Montserrat', sans-serif;
22
- }
23
-
24
- .mono-font {
25
- font-family: 'JetBrains Mono', monospace;
26
- }
27
-
28
- .fade-in {
29
- animation: fadeIn 0.4s ease-in-out forwards;
30
- }
31
-
32
- @keyframes fadeIn {
33
- from {
34
- opacity: 0;
35
- transform: translateY(10px);
36
- }
37
-
38
- to {
39
- opacity: 1;
40
- transform: translateY(0);
41
- }
42
- }
43
-
44
- :root {
45
- --cocoa: #8b5e3c;
46
- --cocoa-l: #c89a6c;
47
- --cocoa-xl: #d4b08a;
48
- --t1: #f0ece6;
49
- --t2: #a89f97;
50
- --border: #2a2a2a;
51
- }
52
-
53
- .bg-glow {
54
- position: absolute;
55
- width: 600px;
56
- height: 600px;
57
- background: radial-gradient(circle, rgba(139,94,60,0.08) 0%, rgba(0,0,0,0) 70%);
58
- top: 50%;
59
- left: 0;
60
- transform: translateY(-50%);
61
- z-index: -1;
62
- pointer-events: none;
63
- }
64
-
65
- .toggle-track {
66
- width: 36px;
67
- height: 20px;
68
- border-radius: 10px;
69
- background: #2a2a2a;
70
- position: relative;
71
- cursor: pointer;
72
- transition: background 0.2s;
73
- flex-shrink: 0;
74
- }
75
-
76
- .toggle-track.active {
77
- background: var(--cocoa-l);
78
- }
79
-
80
- .toggle-thumb {
81
- width: 16px;
82
- height: 16px;
83
- border-radius: 50%;
84
- background: #555;
85
- position: absolute;
86
- top: 2px;
87
- left: 2px;
88
- transition: transform 0.2s;
89
- box-shadow: 0 1px 3px rgba(0,0,0,0.4);
90
- }
91
-
92
- .toggle-track.active .toggle-thumb {
93
- transform: translateX(16px);
94
- background: #000;
95
- }
96
-
97
- .toggle-track.locked {
98
- opacity: 0.4;
99
- cursor: not-allowed;
100
- pointer-events: none;
101
- }
102
-
103
- .custom-select {
104
- appearance: none;
105
- background: #111;
106
- border: 1px solid #2a2a2a;
107
- border-radius: 8px;
108
- padding: 6px 32px 6px 12px;
109
- font-size: 12px;
110
- font-weight: 600;
111
- color: var(--t1);
112
- cursor: pointer;
113
- outline: none;
114
- background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='%23a89f97'%3E%3Cpath fill-rule='evenodd' d='M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z'/%3E%3C/svg%3E");
115
- background-repeat: no-repeat;
116
- background-position: right 10px center;
117
- background-size: 14px;
118
- transition: border-color 0.15s;
119
- }
120
-
121
- .custom-select:hover {
122
- border-color: var(--cocoa);
123
- }
124
-
125
- .custom-select:focus {
126
- border-color: var(--cocoa-l);
127
- }
128
-
129
- .custom-select:disabled {
130
- opacity: 0.4;
131
- cursor: not-allowed;
132
- }
133
-
134
- .stepper-btn {
135
- cursor: pointer;
136
- }
137
-
138
- .stepper-btn:disabled {
139
- opacity: 0.3;
140
- cursor: not-allowed;
141
- }
142
-
143
- .setting-card {
144
- background: #0a0a0a;
145
- border: 1px solid #2a2a2a;
146
- border-radius: 16px;
147
- box-shadow: 0 4px 24px rgba(0,0,0,0.6);
148
- overflow: hidden;
149
- }
150
-
151
- .setting-card-header {
152
- padding: 16px 24px;
153
- border-bottom: 1px solid #1a1a1a;
154
- }
155
-
156
- .setting-row {
157
- display: flex;
158
- align-items: center;
159
- justify-content: space-between;
160
- padding: 14px 24px;
161
- border-bottom: 1px solid #111;
162
- }
163
-
164
- .setting-row:last-child {
165
- border-bottom: none;
166
- }
167
- </style>
168
- </head>
169
-
170
- <body
171
- class="min-h-screen w-full overflow-x-hidden flex flex-col items-center relative z-0"
172
- style="background:#000;color:#f0ece6;font-family:'Montserrat',sans-serif">
173
-
174
- <div class="bg-glow"></div>
175
-
176
- <header class="mt-6 flex flex-col items-center flex-shrink-0 w-full z-10">
177
- <img src="uf_rf.png" alt="UrbanFlow Logo" class="h-28 w-auto object-contain mb-2">
178
- <div class="flex items-center space-x-3">
179
- <span class="w-12 h-[1px]" style="background:#2a2a2a"></span>
180
- <p class="font-montserrat font-bold tracking-[0.25em] uppercase text-[10px] text-center" style="color:#a89f97">
181
- Inference Configuration
182
- </p>
183
- <span class="w-12 h-[1px]" style="background:#2a2a2a"></span>
184
- </div>
185
- </header>
186
-
187
- <main class="flex-1 w-full max-w-4xl mx-auto px-8 py-6 z-10 fade-in">
188
-
189
- <div class="grid grid-cols-2 gap-5 mb-6">
190
-
191
- <!-- Processing Parameters (locked once started) -->
192
- <div class="setting-card" id="card-processing">
193
- <div class="setting-card-header">
194
- <h3 class="font-montserrat font-bold text-base" style="color:#f0ece6">Processing Parameters</h3>
195
- <p class="text-[10px] font-montserrat font-medium mt-1 uppercase tracking-widest" style="color:#a89f97">
196
- Auto-configured &middot; adjustable before start</p>
197
- </div>
198
-
199
- <!-- Image Size -->
200
- <div class="setting-row">
201
- <div>
202
- <div class="text-xs font-semibold" style="color:#f0ece6">Image Size</div>
203
- <div class="text-[10px] mt-0.5" style="color:#a89f97">Model input resolution</div>
204
- </div>
205
- <div class="flex items-center space-x-3 px-3 py-1.5 rounded-full" style="background:#111;border:1px solid #2a2a2a">
206
- <button class="stepper-btn p-1 transition" style="color:#a89f97" onclick="adjust('imgsz',-1)">
207
- <i class="fa-solid fa-chevron-left text-[10px]"></i>
208
- </button>
209
- <span class="mono-font font-bold w-12 text-center text-sm" style="color:#f0ece6" id="val-imgsz">640</span>
210
- <button class="stepper-btn p-1 transition" style="color:#a89f97" onclick="adjust('imgsz',1)">
211
- <i class="fa-solid fa-chevron-right text-[10px]"></i>
212
- </button>
213
- </div>
214
- </div>
215
-
216
- <!-- Confidence -->
217
- <div class="setting-row">
218
- <div>
219
- <div class="text-xs font-semibold" style="color:#f0ece6">Confidence</div>
220
- <div class="text-[10px] mt-0.5" style="color:#a89f97">Minimum detection threshold</div>
221
- </div>
222
- <div class="flex items-center space-x-3 px-3 py-1.5 rounded-full" style="background:#111;border:1px solid #2a2a2a">
223
- <button class="stepper-btn p-1 transition" style="color:#a89f97" onclick="adjust('conf',-1)">
224
- <i class="fa-solid fa-chevron-left text-[10px]"></i>
225
- </button>
226
- <span class="mono-font font-bold w-12 text-center text-sm" style="color:#f0ece6" id="val-conf">0.12</span>
227
- <button class="stepper-btn p-1 transition" style="color:#a89f97" onclick="adjust('conf',1)">
228
- <i class="fa-solid fa-chevron-right text-[10px]"></i>
229
- </button>
230
- </div>
231
- </div>
232
-
233
- <!-- IoU -->
234
- <div class="setting-row">
235
- <div>
236
- <div class="text-xs font-semibold" style="color:#f0ece6">IoU Threshold</div>
237
- <div class="text-[10px] mt-0.5" style="color:#a89f97">Non-max suppression overlap</div>
238
- </div>
239
- <div class="flex items-center space-x-3 px-3 py-1.5 rounded-full" style="background:#111;border:1px solid #2a2a2a">
240
- <button class="stepper-btn p-1 transition" style="color:#a89f97" onclick="adjust('iou',-1)">
241
- <i class="fa-solid fa-chevron-left text-[10px]"></i>
242
- </button>
243
- <span class="mono-font font-bold w-12 text-center text-sm" style="color:#f0ece6" id="val-iou">0.60</span>
244
- <button class="stepper-btn p-1 transition" style="color:#a89f97" onclick="adjust('iou',1)">
245
- <i class="fa-solid fa-chevron-right text-[10px]"></i>
246
- </button>
247
- </div>
248
- </div>
249
-
250
- <!-- Frame Stride -->
251
- <div class="setting-row">
252
- <div>
253
- <div class="text-xs font-semibold" style="color:#f0ece6">Frame Stride</div>
254
- <div class="text-[10px] mt-0.5" style="color:#a89f97">Frames skipped between detections</div>
255
- </div>
256
- <div class="flex items-center space-x-3 px-3 py-1.5 rounded-full" style="background:#111;border:1px solid #2a2a2a">
257
- <button class="stepper-btn p-1 transition" style="color:#a89f97" onclick="adjust('detect_stride',-1)">
258
- <i class="fa-solid fa-chevron-left text-[10px]"></i>
259
- </button>
260
- <span class="mono-font font-bold w-12 text-center text-sm" style="color:#f0ece6" id="val-detect_stride">3</span>
261
- <button class="stepper-btn p-1 transition" style="color:#a89f97" onclick="adjust('detect_stride',1)">
262
- <i class="fa-solid fa-chevron-right text-[10px]"></i>
263
- </button>
264
- </div>
265
- </div>
266
-
267
- <!-- Report Format -->
268
- <div class="setting-row">
269
- <div>
270
- <div class="text-xs font-semibold" style="color:#f0ece6">Report Format</div>
271
- <div class="text-[10px] mt-0.5" style="color:#a89f97">Export file type for report charts</div>
272
- </div>
273
- <select class="custom-select" id="sel-report-format">
274
- <option value="png" selected>PNG Image</option>
275
- <option value="pdf">PDF Document</option>
276
- </select>
277
- </div>
278
-
279
- <!-- Annotated Video -->
280
- <div class="setting-row">
281
- <div>
282
- <div class="text-xs font-semibold" style="color:#f0ece6">Export Annotated Video</div>
283
- <div class="text-[10px] mt-0.5" style="color:#a89f97">Bounding boxes · track IDs · spatial boundary</div>
284
- </div>
285
- <div class="toggle-track" id="tog-annotated" onclick="toggleSwitch(this)">
286
- <div class="toggle-thumb"></div>
287
- </div>
288
- </div>
289
- </div>
290
-
291
- <!-- Display Preferences (editable anytime) -->
292
- <div class="setting-card" id="card-display">
293
- <div class="setting-card-header">
294
- <h3 class="font-montserrat font-bold text-base" style="color:#f0ece6">Display Preferences</h3>
295
- <p class="text-[10px] font-montserrat font-medium mt-1 uppercase tracking-widest" style="color:#a89f97">
296
- Editable anytime &middot; applies to live charts</p>
297
- </div>
298
-
299
- <!-- Chart Color Palette -->
300
- <div class="setting-row">
301
- <div>
302
- <div class="text-xs font-semibold" style="color:#f0ece6">Chart Color Palette</div>
303
- <div class="text-[10px] mt-0.5" style="color:#a89f97">Color scheme for all dashboard charts</div>
304
- </div>
305
- <select class="custom-select" id="sel-palette">
306
- <option value="default" selected>Default</option>
307
- <option value="vibrant">Vibrant</option>
308
- <option value="corporate">Corporate</option>
309
- <option value="neon">Neon Night</option>
310
- <option value="earth">Earth Tones</option>
311
- <option value="ocean">Ocean Breeze</option>
312
- <option value="sunset">Sunset Glow</option>
313
- <option value="midnight">Midnight Deep</option>
314
- <option value="gold">Monochrome Gold</option>
315
- </select>
316
- </div>
317
-
318
- <!-- Palette Preview -->
319
- <div class="px-6 py-4">
320
- <div class="text-[10px] font-bold uppercase tracking-widest mb-3" style="color:#a89f97">Palette Preview</div>
321
- <div class="flex gap-2" id="palette-preview"></div>
322
- </div>
323
- </div>
324
-
325
- </div>
326
-
327
- <!-- Start Button -->
328
- <button id="btn-start" onclick="startProcessing()"
329
- class="w-full py-4 rounded-full font-montserrat font-semibold transition-all text-sm flex justify-center items-center shadow-lg"
330
- style="background:#0a0a0a;border:1px solid var(--cocoa);color:var(--cocoa-l)">
331
- Initiate Processing <i class="fa-solid fa-arrow-right ml-3 text-sm"></i>
332
- </button>
333
-
334
- </main>
335
-
336
- <script>
337
- // =========== Config params ===========
338
- const paramDefs = {
339
- imgsz: { step: 32, min: 640, max: 1280, decimals: 0 },
340
- conf: { step: 0.01, min: 0.08, max: 1.0, decimals: 2 },
341
- iou: { step: 0.05, min: 0.5, max: 1.0, decimals: 2 },
342
- detect_stride: { step: 1, min: 1, max: 10, decimals: 0 }
343
- };
344
-
345
- // =========== Palette definitions ===========
346
- const PALETTES = {
347
- default: { congestion: '#f97316', dominance: '#14b8a6', flow: '#3b82f6', label: 'Default' },
348
- vibrant: { congestion: '#ff2d55', dominance: '#a855f7', flow: '#22d3ee', label: 'Vibrant' },
349
- corporate: { congestion: '#1e40af', dominance: '#0d9488', flow: '#475569', label: 'Corporate' },
350
- neon: { congestion: '#f0abfc', dominance: '#22d3ee', flow: '#a855f7', label: 'Neon Night' },
351
- earth: { congestion: '#84cc16', dominance: '#22c55e', flow: '#f59e0b', label: 'Earth Tones' },
352
- ocean: { congestion: '#06b6d4', dominance: '#3b82f6', flow: '#14b8a6', label: 'Ocean Breeze' },
353
- sunset: { congestion: '#f59e0b', dominance: '#f43f5e', flow: '#fb923c', label: 'Sunset Glow' },
354
- midnight: { congestion: '#38bdf8', dominance: '#1d4ed8', flow: '#f8fafc', label: 'Midnight Deep' },
355
- gold: { congestion: '#fbbf24', dominance: '#a8a29e', flow: '#f5f5f4', label: 'Monochrome Gold' }
356
- };
357
-
358
- // =========== Load run data from initial.html ===========
359
- const raw = sessionStorage.getItem('funky_run');
360
- if (!raw) { window.location.href = '/'; }
361
- const runData = JSON.parse(raw);
362
- const config = runData.config;
363
-
364
- // Populate stepper values from auto-calculated config
365
- function renderValues() {
366
- for (const key of Object.keys(paramDefs)) {
367
- const d = paramDefs[key].decimals;
368
- const val = config[key];
369
- document.getElementById('val-' + key).innerText = d ? val.toFixed(d) : val;
370
- }
371
- }
372
- renderValues();
373
-
374
- // =========== Stepper ===========
375
- function adjust(key, dir) {
376
- const p = paramDefs[key];
377
- let val = config[key] + dir * p.step;
378
- val = Math.max(p.min, Math.min(p.max, val));
379
- val = p.decimals ? parseFloat(val.toFixed(p.decimals)) : Math.round(val);
380
- config[key] = val;
381
- document.getElementById('val-' + key).innerText = p.decimals ? val.toFixed(p.decimals) : val;
382
- }
383
-
384
- // =========== Toggle ===========
385
- function toggleSwitch(el) {
386
- if (el.classList.contains('locked')) return;
387
- el.classList.toggle('active');
388
- }
389
-
390
- // =========== Palette preview ===========
391
- function updatePalettePreview() {
392
- const key = document.getElementById('sel-palette').value;
393
- const pal = PALETTES[key];
394
- const container = document.getElementById('palette-preview');
395
- const colors = [
396
- { color: pal.congestion, label: 'Congestion' },
397
- { color: pal.dominance, label: 'Dominance' },
398
- { color: pal.flow, label: 'Flow' }
399
- ];
400
- container.innerHTML = colors.map(c =>
401
- `<div class="flex-1 rounded-lg overflow-hidden border border-slate-100">
402
- <div class="h-8" style="background:${c.color}"></div>
403
- <div class="text-[9px] font-bold text-slate-500 text-center py-1.5 bg-slate-50">${c.label}</div>
404
- </div>`
405
- ).join('');
406
- }
407
- document.getElementById('sel-palette').addEventListener('change', updatePalettePreview);
408
- updatePalettePreview();
409
-
410
- // =========== Start Processing ===========
411
- function startProcessing() {
412
- // Gather settings
413
- const settings = {
414
- reportFormat: document.getElementById('sel-report-format').value,
415
- annotatedVideo: document.getElementById('tog-annotated').classList.contains('active'),
416
- palette: document.getElementById('sel-palette').value
417
- };
418
-
419
- // Update config from steppers (already in config object)
420
- // Save everything to sessionStorage
421
- sessionStorage.setItem('funky_run', JSON.stringify({
422
- video_id: runData.video_id,
423
- line: runData.line,
424
- config: config,
425
- settings: settings
426
- }));
427
-
428
- window.location.href = 'vehicles.html';
429
- }
430
- </script>
431
- </body>
432
-
433
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/uf_logo_b.png DELETED

Git LFS Details

  • SHA256: 948556674b4c6069fc9ac3f38a0f34d2f010b3eb612475e50b6a3f2f82bf141e
  • Pointer size: 132 Bytes
  • Size of remote file: 2.61 MB
frontend/uf_logo_w.png DELETED

Git LFS Details

  • SHA256: 61bde29d9fccc2bba0c1de61ce36c630062558453d540a395a22c1aa5e51dcc1
  • Pointer size: 132 Bytes
  • Size of remote file: 4.09 MB
frontend/vehicles.html CHANGED
@@ -5,7 +5,7 @@
5
  <meta charset="UTF-8">
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
  <title>UrbanFlow</title>
8
- <link rel="icon" type="image/svg+xml" href="rf.png">
9
  <script src="https://cdn.tailwindcss.com"></script>
10
  <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
11
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
@@ -13,456 +13,7 @@
13
  href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Montserrat:wght@400;500;600;700;800;900&display=swap"
14
  rel="stylesheet">
15
  <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
16
- <style>
17
- :root {
18
- --cocoa: #8b5e3c;
19
- --cocoa-l: #c89a6c;
20
- --cocoa-xl: #d4b08a;
21
- }
22
-
23
- body {
24
- font-family: 'Montserrat', sans-serif;
25
- background-color: #000000;
26
- color: #f0ece6;
27
- }
28
-
29
- .mono-font {
30
- font-family: 'JetBrains Mono', monospace;
31
- }
32
-
33
- ::-webkit-scrollbar {
34
- width: 4px;
35
- height: 4px;
36
- }
37
-
38
- ::-webkit-scrollbar-track {
39
- background: #000000;
40
- }
41
-
42
- ::-webkit-scrollbar-thumb {
43
- background: #222222;
44
- border-radius: 4px;
45
- }
46
-
47
- ::-webkit-scrollbar-thumb:hover {
48
- background: #333333;
49
- }
50
-
51
- .info-wrap {
52
- position: relative;
53
- display: inline-flex;
54
- align-items: center;
55
- margin-left: 6px;
56
- }
57
-
58
- .info-btn {
59
- display: inline-flex;
60
- align-items: center;
61
- justify-content: center;
62
- width: 14px;
63
- height: 14px;
64
- border-radius: 50%;
65
- background: #444444 !important;
66
- color: #ffffff !important;
67
- font-size: 7px;
68
- cursor: pointer;
69
- transition: all 0.2s ease;
70
- }
71
-
72
- .info-btn:hover {
73
- background: #666666 !important;
74
- }
75
-
76
- .info-tip {
77
- display: none;
78
- position: fixed;
79
- z-index: 9999;
80
- background: #0a0a0a;
81
- color: #aaaaaa;
82
- font-size: 10px;
83
- font-weight: 500;
84
- line-height: 1.4;
85
- padding: 8px 12px;
86
- border-radius: 6px;
87
- max-width: 240px;
88
- box-shadow: 0 10px 30px rgba(0, 0, 0, 0.8);
89
- border: 1px solid #222222;
90
- pointer-events: none;
91
- text-transform: none;
92
- letter-spacing: normal;
93
- }
94
-
95
- /* Nav states */
96
- .nav-item-active {
97
- background-color: #111111 !important;
98
- color: var(--cocoa-xl) !important;
99
- border-left: 2px solid var(--cocoa-l) !important;
100
- }
101
-
102
- .nav-item-inactive {
103
- color: #555555 !important;
104
- }
105
-
106
- .nav-item-inactive:hover {
107
- color: #f0ece6 !important;
108
- background-color: #050505 !important;
109
- }
110
-
111
- /* Card Overrides */
112
- .bg-white {
113
- background-color: #0a0a0a !important;
114
- }
115
-
116
- .border-slate-200,
117
- .border-slate-100,
118
- .border-slate-50,
119
- .border-neutral-800,
120
- .border-neutral-900 {
121
- border-color: #2a2a2a !important;
122
- }
123
-
124
- .bg-slate-50\/50,
125
- .bg-slate-50,
126
- .bg-slate-900,
127
- .bg-neutral-900 {
128
- background-color: #0c0c0c !important;
129
- }
130
-
131
- .text-slate-900,
132
- .text-slate-800,
133
- .text-slate-700,
134
- .text-neutral-900 {
135
- color: #ffffff !important;
136
- }
137
-
138
- .text-slate-600,
139
- .text-slate-500,
140
- .text-slate-400,
141
- .text-neutral-500,
142
- .text-neutral-400 {
143
- color: #888888 !important;
144
- }
145
-
146
- .shadow-sm {
147
- box-shadow: none !important;
148
- }
149
-
150
- /* Controls */
151
- .toggle-track {
152
- width: 32px;
153
- height: 18px;
154
- border-radius: 9px;
155
- background: #222222;
156
- position: relative;
157
- cursor: pointer;
158
- }
159
-
160
- .toggle-track.active {
161
- background: var(--cocoa-l);
162
- }
163
-
164
- .toggle-thumb {
165
- width: 14px;
166
- height: 14px;
167
- border-radius: 50%;
168
- background: #555555;
169
- position: absolute;
170
- top: 2px;
171
- left: 2px;
172
- transition: all 0.2s ease;
173
- }
174
-
175
- .toggle-track.active .toggle-thumb {
176
- transform: translateX(14px);
177
- background: #000000;
178
- }
179
-
180
- .custom-select {
181
- appearance: none;
182
- background-color: #111111;
183
- border: 1px solid #222222;
184
- border-radius: 6px;
185
- padding: 4px 24px 4px 10px;
186
- font-size: 11px;
187
- font-weight: 600;
188
- color: #ffffff;
189
- outline: none;
190
- background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='%23666666'%3E%3Cpath fill-rule='evenodd' d='M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z'/%3E%3C/svg%3E");
191
- background-repeat: no-repeat;
192
- background-position: right 8px center;
193
- background-size: 12px;
194
- }
195
-
196
- .s-stepper {
197
- display: inline-flex;
198
- border: 1px solid #222222;
199
- border-radius: 6px;
200
- background: #111111;
201
- overflow: hidden;
202
- }
203
-
204
- .s-stepper button {
205
- padding: 4px 8px;
206
- color: #666666;
207
- font-size: 12px;
208
- }
209
-
210
- .s-stepper button:hover {
211
- background: #1a1a1a;
212
- color: #ffffff;
213
- }
214
-
215
- .s-stepper .s-val {
216
- min-width: 40px;
217
- text-align: center;
218
- font-family: 'JetBrains Mono', monospace;
219
- font-size: 12px;
220
- font-weight: 700;
221
- color: #ffffff;
222
- padding: 4px 0;
223
- border-left: 1px solid #222222;
224
- border-right: 1px solid #222222;
225
- }
226
-
227
- .s-row {
228
- display: flex;
229
- align-items: center;
230
- justify-content: space-between;
231
- padding: 12px 0;
232
- border-bottom: 1px solid #1a1a1a;
233
- }
234
-
235
- .s-row:last-child {
236
- border-bottom: none;
237
- }
238
-
239
- #proc-bar {
240
- background-color: var(--cocoa-l) !important;
241
- }
242
-
243
- #proc-label {
244
- color: #ffffff !important;
245
- }
246
-
247
- .s-row.disabled {
248
- opacity: 0.65 !important;
249
- }
250
-
251
- .s-row.disabled .s-stepper,
252
- .s-row.disabled .custom-select,
253
- .s-row.disabled .toggle-track,
254
- .s-row.disabled .chip-container {
255
- pointer-events: none !important;
256
- opacity: 0.5 !important;
257
- }
258
-
259
- .s-row.disabled .info-wrap {
260
- pointer-events: auto !important;
261
- opacity: 1 !important;
262
- }
263
-
264
-
265
- #btn-start-processing {
266
- font-family: 'Montserrat', sans-serif !important;
267
- }
268
-
269
- /* Chips */
270
- .chip-container {
271
- display: flex;
272
- flex-wrap: wrap;
273
- gap: 8px;
274
- margin-top: 12px;
275
- padding-top: 12px;
276
- border-top: 1px solid #1a1a1a;
277
- transition: all 0.3s ease;
278
- }
279
-
280
- .chip {
281
- display: inline-flex;
282
- align-items: center;
283
- gap: 6px;
284
- padding: 6px 14px;
285
- border-radius: 9999px;
286
- font-size: 10px;
287
- font-weight: 700;
288
- cursor: pointer;
289
- transition: all 0.2s ease;
290
- user-select: none;
291
- border: 1px solid #333333;
292
- background: rgba(255, 255, 255, 0.03);
293
- color: #888888;
294
- }
295
-
296
- .chip.active {
297
- background: var(--cocoa-l);
298
- color: #000000;
299
- border-color: var(--cocoa-l);
300
- }
301
-
302
- .chip.frozen {
303
- background: rgba(255, 255, 255, 0.4);
304
- color: #000000;
305
- border-color: transparent;
306
- cursor: default !important;
307
- pointer-events: none;
308
- }
309
-
310
- .chip:hover {
311
- border-color: #666666;
312
- }
313
-
314
- .chip.active:hover {
315
- background: var(--cocoa-xl);
316
- }
317
-
318
- .chip i {
319
- font-size: 9px;
320
- }
321
-
322
- .hidden-chip-container {
323
- height: 0;
324
- opacity: 0;
325
- overflow: hidden;
326
- margin-top: 0;
327
- padding-top: 0;
328
- border-top: none;
329
- }
330
-
331
- /* Toast Notifications */
332
- #toast-container {
333
- position: fixed;
334
- bottom: 20px;
335
- right: 20px;
336
- z-index: 10000;
337
- display: flex;
338
- flex-direction: column;
339
- gap: 8px;
340
- pointer-events: none;
341
- }
342
- .toast {
343
- background: #111;
344
- border: 1px solid #2a2a2a;
345
- color: #f0ece6;
346
- font-size: 11px;
347
- font-weight: 600;
348
- padding: 10px 18px;
349
- border-radius: 10px;
350
- display: flex;
351
- align-items: center;
352
- gap: 8px;
353
- pointer-events: auto;
354
- animation: toastIn 0.3s ease-out;
355
- max-width: 320px;
356
- }
357
- .toast.toast-out { animation: toastOut 0.3s ease-in forwards; }
358
- .toast-success { border-color: #166534; }
359
- .toast-success i { color: #22c55e; }
360
- .toast-error { border-color: #7f1d1d; }
361
- .toast-error i { color: #ef4444; }
362
- .toast-info i { color: var(--cocoa-l); }
363
- @keyframes toastIn { from { opacity: 0; transform: translateX(40px); } to { opacity: 1; transform: translateX(0); } }
364
- @keyframes toastOut { from { opacity: 1; } to { opacity: 0; transform: translateX(40px); } }
365
-
366
- /* Stats Empty State */
367
- .stats-empty-overlay {
368
- position: absolute;
369
- inset: 0;
370
- z-index: 50;
371
- display: flex;
372
- flex-direction: column;
373
- align-items: center;
374
- justify-content: center;
375
- background: rgba(10, 10, 10, 0.85);
376
- backdrop-filter: blur(8px);
377
- border-radius: 12px;
378
- }
379
-
380
- /* Mobile Responsive */
381
- @media (max-width: 1024px) {
382
- aside.w-60 { display: none; }
383
- .mobile-nav { display: flex !important; }
384
- #tab-overview { grid-template-columns: repeat(1, 1fr) !important; }
385
- #tab-overview > div { grid-column: span 1 !important; }
386
- }
387
- @media (max-width: 768px) {
388
- .grid-cols-3 { grid-template-columns: 1fr !important; }
389
- .grid-cols-2 { grid-template-columns: 1fr !important; }
390
- main { padding: 8px !important; }
391
- }
392
-
393
- /* Feedback form */
394
- .fb-textarea {
395
- background: #111;
396
- border: 1px solid #2a2a2a;
397
- border-radius: 8px;
398
- color: #f0ece6;
399
- font-size: 12px;
400
- padding: 12px;
401
- width: 100%;
402
- min-height: 120px;
403
- resize: vertical;
404
- font-family: 'Inter', sans-serif;
405
- }
406
- .fb-textarea:focus { outline: none; border-color: var(--cocoa-l); }
407
- .fb-select {
408
- background: #111;
409
- border: 1px solid #2a2a2a;
410
- border-radius: 8px;
411
- color: #f0ece6;
412
- font-size: 11px;
413
- padding: 8px 12px;
414
- width: 100%;
415
- font-family: 'Inter', sans-serif;
416
- }
417
- .fb-select:focus { outline: none; border-color: var(--cocoa-l); }
418
- .fb-stars { display: flex; gap: 6px; }
419
- .fb-star {
420
- font-size: 22px;
421
- color: #333;
422
- cursor: pointer;
423
- transition: color 0.15s;
424
- }
425
- .fb-star.active, .fb-star:hover { color: var(--cocoa-l); }
426
- .fb-chip {
427
- background: #050505;
428
- border: 1px solid #222;
429
- border-radius: 8px;
430
- color: #666;
431
- font-size: 10px;
432
- font-weight: 700;
433
- padding: 12px;
434
- cursor: pointer;
435
- transition: all 0.2s ease;
436
- text-align: center;
437
- text-transform: uppercase;
438
- tracking-widest: 0.05em;
439
- }
440
- .fb-chip:hover { border-color: #444; color: #999; }
441
- .fb-chip.active {
442
- border-color: var(--cocoa-l);
443
- background: #111;
444
- color: #fff;
445
- box-shadow: 0 0 15px rgba(200, 154, 108, 0.15);
446
- }
447
- .fb-emoji-btn {
448
- background: #111;
449
- border: 1px solid #2a2a2a;
450
- border-radius: 8px;
451
- color: #555;
452
- flex: 1;
453
- text-align: center;
454
- padding: 10px 4px;
455
- cursor: pointer;
456
- transition: all 0.2s ease;
457
- }
458
- .fb-emoji-btn:hover { border-color: #444; color: #888; }
459
- .fb-emoji-btn.active {
460
- border-color: var(--cocoa-l);
461
- background: #1a1a1a;
462
- color: var(--cocoa-l);
463
- box-shadow: 0 0 15px rgba(200, 154, 108, 0.15);
464
- }
465
- </style>
466
  </head>
467
 
468
  <body class="bg-black text-white h-screen w-screen overflow-hidden flex">
@@ -470,7 +21,7 @@
470
  <!-- Sidebar -->
471
  <aside class="w-60 bg-white shadow-xl flex flex-col z-20 flex-shrink-0 border-r border-slate-200 relative">
472
  <div class="h-28 bg-black flex items-center justify-center px-4 my-2 border-b border-slate-800 flex-shrink-0">
473
- <img id="sidebar-logo-top" src="uf_rf.png" alt="UrbanFlow Logo" class="h-24 w-auto object-contain">
474
  </div>
475
  <nav class="flex-1 px-4 py-4 space-y-1.5 overflow-y-auto text-sm">
476
  <a onclick="switchTab('about')" id="nav-about"
@@ -514,7 +65,7 @@
514
 
515
  <!-- Mobile Navigation (hidden on desktop) -->
516
  <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">
517
- <img src="uf_rf.png" alt="UF" class="h-8">
518
  <div class="flex gap-1">
519
  <button onclick="switchTab('about')" class="text-[9px] px-2 py-1 rounded" style="color:#a89f97"><i class="fa-solid fa-circle-info"></i></button>
520
  <button onclick="switchTab('overview')" class="text-[9px] px-2 py-1 rounded" style="color:#a89f97"><i class="fa-solid fa-desktop"></i></button>
@@ -1291,1064 +842,7 @@
1291
 
1292
  </main>
1293
 
1294
- <script>
1295
- // =========== Rolling Counter ===========
1296
- function animateValue(obj, start, end, duration) {
1297
- let startTimestamp = null;
1298
- const isPct = typeof end === 'string' && end.includes('%');
1299
- const endNum = isPct ? parseFloat(end) : end;
1300
- const startNum = parseFloat(start) || 0;
1301
-
1302
- const step = (timestamp) => {
1303
- if (!startTimestamp) startTimestamp = timestamp;
1304
- const progress = Math.min((timestamp - startTimestamp) / duration, 1);
1305
- const ease = progress * (2 - progress);
1306
- const current = ease * (endNum - startNum) + startNum;
1307
-
1308
- if (isPct) {
1309
- obj.innerText = current.toFixed(1) + '%';
1310
- } else {
1311
- obj.innerText = Math.floor(current);
1312
- }
1313
-
1314
- if (progress < 1) {
1315
- window.requestAnimationFrame(step);
1316
- } else {
1317
- obj.innerText = end;
1318
- }
1319
- };
1320
- window.requestAnimationFrame(step);
1321
- }
1322
-
1323
- // =========== Tooltip ===========
1324
- // Position and toggle tooltip visibility
1325
- document.addEventListener('mouseover', e => {
1326
- const wrap = e.target.closest('.info-wrap');
1327
- if (!wrap) return;
1328
- const tip = wrap.querySelector('.info-tip');
1329
- if (!tip) return;
1330
-
1331
- tip.style.display = 'block';
1332
- const rect = wrap.getBoundingClientRect();
1333
- const tipH = tip.offsetHeight || 60;
1334
- if (rect.bottom + tipH + 10 > window.innerHeight) {
1335
- tip.style.top = (rect.top - tipH - 6) + 'px';
1336
- } else {
1337
- tip.style.top = (rect.bottom + 6) + 'px';
1338
- }
1339
- tip.style.left = Math.min(rect.left, window.innerWidth - 300) + 'px';
1340
- });
1341
-
1342
- document.addEventListener('mouseout', e => {
1343
- const wrap = e.target.closest('.info-wrap');
1344
- if (!wrap) return;
1345
- const tip = wrap.querySelector('.info-tip');
1346
- if (tip) tip.style.display = 'none';
1347
- });
1348
-
1349
- document.addEventListener('click', e => {
1350
- const btn = e.target.closest('.info-btn');
1351
- if (!btn) return;
1352
- const wrap = btn.closest('.info-wrap');
1353
- if (!wrap) return;
1354
- const tip = wrap.querySelector('.info-tip');
1355
- if (!tip) return;
1356
-
1357
- const isVisible = tip.style.display === 'block';
1358
- tip.style.display = isVisible ? 'none' : 'block';
1359
-
1360
- if (!isVisible) {
1361
- const rect = wrap.getBoundingClientRect();
1362
- const tipH = tip.offsetHeight || 60;
1363
- if (rect.bottom + tipH + 10 > window.innerHeight) {
1364
- tip.style.top = (rect.top - tipH - 6) + 'px';
1365
- } else {
1366
- tip.style.top = (rect.bottom + 6) + 'px';
1367
- }
1368
- tip.style.left = Math.min(rect.left, window.innerWidth - 300) + 'px';
1369
- }
1370
- });
1371
-
1372
- // =========== Tab switching ===========
1373
- function switchTab(tab) {
1374
- ['about', 'overview', 'run-details', 'reports', 'settings', 'feedback'].forEach(t => {
1375
- const el = document.getElementById('tab-' + t);
1376
- const nav = document.getElementById('nav-' + t);
1377
- if (el) el.classList.toggle('hidden', tab !== t);
1378
- if (nav) {
1379
- if (tab === t) {
1380
- nav.classList.add('nav-item-active');
1381
- nav.classList.remove('nav-item-inactive');
1382
- } else {
1383
- nav.classList.remove('nav-item-active');
1384
- nav.classList.add('nav-item-inactive');
1385
- }
1386
- }
1387
- });
1388
- }
1389
-
1390
- // =========== Toast System ===========
1391
- function showToast(message, type) {
1392
- type = type || 'info';
1393
- const icons = { success: 'fa-check-circle', error: 'fa-circle-xmark', info: 'fa-circle-info' };
1394
- const el = document.createElement('div');
1395
- el.className = `toast toast-${type}`;
1396
- el.innerHTML = `<i class="fa-solid ${icons[type] || icons.info}"></i> ${message}`;
1397
- document.getElementById('toast-container').appendChild(el);
1398
- setTimeout(() => { el.classList.add('toast-out'); setTimeout(() => el.remove(), 300); }, 3000);
1399
- }
1400
-
1401
- // =========== Keyboard Shortcuts ===========
1402
- const TAB_KEYS = { '1': 'about', '2': 'overview', '3': 'run-details', '4': 'reports', '5': 'settings', '6': 'feedback' };
1403
- document.addEventListener('keydown', function(e) {
1404
- if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') return;
1405
- if (TAB_KEYS[e.key]) { switchTab(TAB_KEYS[e.key]); return; }
1406
- if (e.key === 'd' || e.key === 'D') {
1407
- const vid = document.body.dataset.lastVideoId;
1408
- if (vid) {
1409
- window.open(`/bundle/${vid}`, '_blank');
1410
- showToast('Download started', 'success');
1411
- }
1412
- }
1413
- });
1414
-
1415
- // =========== Feedback ===========
1416
- let _fbRating = 0;
1417
- let _fbEmojis = {
1418
- 'fb-recommend': null,
1419
- 'fb-security': null,
1420
- 'fb-integration': null,
1421
- 'fb-ease': null
1422
- };
1423
-
1424
- function setRating(n) {
1425
- _fbRating = n;
1426
- document.querySelectorAll('.fb-star').forEach(s => {
1427
- s.classList.toggle('active', parseInt(s.dataset.v) <= n);
1428
- });
1429
- }
1430
-
1431
- function setEmoji(el, qId, val) {
1432
- _fbEmojis[qId] = val;
1433
- const container = document.getElementById(qId);
1434
- if (container) {
1435
- container.querySelectorAll('.fb-emoji-btn').forEach(btn => btn.classList.remove('active'));
1436
- }
1437
- el.classList.add('active');
1438
- }
1439
-
1440
- async function submitFeedback() {
1441
- const type = document.getElementById('fb-type').value;
1442
- const text = document.getElementById('fb-text').value.trim();
1443
- const usecase = document.getElementById('fb-usecase').value;
1444
-
1445
- const priorities = [];
1446
- document.querySelectorAll('#fb-priorities .fb-chip.active').forEach(c => {
1447
- priorities.push(c.getAttribute('data-val'));
1448
- });
1449
-
1450
- if (!text && _fbRating === 0 && !_fbEmojis['fb-recommend'] && !_fbEmojis['fb-security'] && !_fbEmojis['fb-integration'] && !_fbEmojis['fb-ease']) {
1451
- showToast("Please provide a rating, some emojis, or word feedback", "error");
1452
- return;
1453
- }
1454
-
1455
- const payload = {
1456
- rating: _fbRating,
1457
- emojis: _fbEmojis,
1458
- type: type,
1459
- usecase: usecase,
1460
- priorities: priorities,
1461
- details: text,
1462
- timestamp: new Date().toISOString()
1463
- };
1464
- const res = await fetch('/api/feedback', {
1465
- method: 'POST',
1466
- headers: { 'Content-Type': 'application/json' },
1467
- body: JSON.stringify(payload)
1468
- });
1469
- if (res.ok) {
1470
- showToast('Thank you for your feedback!', 'success');
1471
- document.getElementById('fb-text').value = '';
1472
- document.querySelectorAll('#fb-priorities .fb-chip').forEach(c => c.classList.remove('active'));
1473
-
1474
- // Reset Emojis
1475
- Object.keys(_fbEmojis).forEach(k => {
1476
- _fbEmojis[k] = null;
1477
- const c = document.getElementById(k);
1478
- if (c) c.querySelectorAll('.fb-emoji-btn').forEach(b => b.classList.remove('active'));
1479
- });
1480
-
1481
- _fbRating = 0;
1482
- setRating(0);
1483
- } else {
1484
- showToast('Failed to submit — please try again', 'error');
1485
- }
1486
- }
1487
-
1488
- // =========== PCU Calculation (client-side mirror) ===========
1489
- 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};
1490
- function calcPCU(classIn, classOut) {
1491
- let total = 0;
1492
- for (const [k, v] of Object.entries(classIn)) total += (PCU_TABLE[parseInt(k)] || 1) * v;
1493
- for (const [k, v] of Object.entries(classOut)) total += (PCU_TABLE[parseInt(k)] || 1) * v;
1494
- return Math.round(total * 10) / 10;
1495
- }
1496
-
1497
- // =========== Insights Rendering ===========
1498
- function renderInsights(d) {
1499
- const panel = document.getElementById('insights-panel');
1500
- panel.classList.remove('hidden');
1501
-
1502
- // Speed distribution bars
1503
- const dist = d.speed_distribution || {};
1504
- const bars = document.getElementById('speed-bars');
1505
- const colors = { slow: '#ef4444', normal: '#eab308', fast: '#22c55e' };
1506
- const labels = { slow: 'Slow', normal: 'Normal', fast: 'Fast' };
1507
- bars.innerHTML = ['slow', 'normal', 'fast'].map(cat => {
1508
- const pct = dist[cat] || 0;
1509
- const h = Math.max(8, pct * 1.2);
1510
- return `<div class="flex flex-col items-center gap-1">
1511
- <span class="text-[10px] font-bold" style="color:${colors[cat]}">${pct}%</span>
1512
- <div style="width:36px;height:${h}px;background:${colors[cat]};border-radius:6px;transition:height 0.5s"></div>
1513
- <span class="text-[9px] font-bold text-slate-500 uppercase">${labels[cat]}</span>
1514
- </div>`;
1515
- }).join('');
1516
-
1517
- // Congestion insights
1518
- const ci = document.getElementById('congestion-insights');
1519
- const pcu = d.pcu || {};
1520
- ci.innerHTML = [
1521
- infoRow('Total PCU', pcu.total_pcu || 0, 'Passenger Car Units (IRC:106-1990). Normalizes mixed traffic.'),
1522
- infoRow('PCU In / Out', `${pcu.pcu_in || 0} / ${pcu.pcu_out || 0}`, 'Directional PCU split.'),
1523
- infoRow('Speed Profile', `${dist.slow || 0}% slow · ${dist.normal || 0}% normal · ${dist.fast || 0}% fast`, 'Relative speed categories within this video.'),
1524
- ].join('');
1525
-
1526
- // ---- Also populate Stats tab cards ----
1527
- // Speed card in Stats
1528
- const speedCard = document.getElementById('speed-stats-card');
1529
- if (speedCard) {
1530
- speedCard.innerHTML = `<div class="flex gap-5 items-end justify-center w-full">
1531
- ${['slow', 'normal', 'fast'].map(cat => {
1532
- const pct = dist[cat] || 0;
1533
- const h = Math.max(12, pct * 1.0);
1534
- return `<div class="flex flex-col items-center gap-1.5 flex-1">
1535
- <span class="text-lg font-black" style="color:${colors[cat]}">${pct}%</span>
1536
- <div style="width:100%;max-width:48px;height:${h}px;background:${colors[cat]};border-radius:8px;transition:height 0.5s"></div>
1537
- <span class="text-[10px] font-bold text-slate-500 uppercase">${labels[cat]}</span>
1538
- </div>`;
1539
- }).join('')}
1540
- </div>`;
1541
- }
1542
-
1543
- // PCU card in Stats
1544
- const pcuCard = document.getElementById('pcu-stats-card');
1545
- if (pcuCard) {
1546
- pcuCard.innerHTML = `<div class="space-y-3 w-full">
1547
- <div class="flex justify-between items-center">
1548
- <span class="text-xs font-medium text-slate-500">Total PCU</span>
1549
- <span class="text-2xl font-black" style="color:#8b5e3c">${pcu.total_pcu || 0}</span>
1550
- </div>
1551
- <div class="flex gap-3">
1552
- <div class="flex-1 bg-green-50 rounded-lg p-2.5 text-center border border-green-100">
1553
- <div class="text-lg font-bold text-green-700">${pcu.pcu_in || 0}</div>
1554
- <div class="text-[9px] font-bold text-green-500 uppercase">PCU In</div>
1555
- </div>
1556
- <div class="flex-1 bg-red-50 rounded-lg p-2.5 text-center border border-red-100">
1557
- <div class="text-lg font-bold text-red-700">${pcu.pcu_out || 0}</div>
1558
- <div class="text-[9px] font-bold text-red-500 uppercase">PCU Out</div>
1559
- </div>
1560
- </div>
1561
- </div>`;
1562
- }
1563
- }
1564
-
1565
- // =========== Run Details helpers ===========
1566
- function detailRow(label, value, extra) {
1567
- extra = extra || '';
1568
- return `<div class="flex justify-between items-center border-b border-slate-800 pb-2">
1569
- <span class="text-xs font-medium text-slate-500 mono-font">${label}</span>
1570
- <span class="text-sm font-bold text-white">${value}${extra}</span>
1571
- </div>`;
1572
- }
1573
-
1574
- function infoRow(label, value, tip, extra) {
1575
- extra = extra || '';
1576
- return `<div class="flex justify-between items-center border-b border-slate-800 pb-2 relative">
1577
- <span class="text-xs font-medium text-slate-500 mono-font flex items-center">${label}
1578
- <span class="info-wrap"><span class="info-btn"><i class="fa-solid fa-info"></i></span>
1579
- <span class="info-tip">${tip}</span></span>
1580
- </span>
1581
- <span class="text-sm font-bold text-white">${value}${extra}</span>
1582
- </div>`;
1583
- }
1584
-
1585
- function boolBadge(val) {
1586
- if (val) return `<span class="inline-flex items-center bg-green-50 text-green-700 text-[10px] font-bold px-2 py-0.5 rounded border border-green-200"><i class="fa-solid fa-check mr-1"></i>TRUE</span>`;
1587
- return `<span class="text-[10px] font-bold text-slate-300">FALSE</span>`;
1588
- }
1589
-
1590
- function populateRunDetails(c) {
1591
- const res = c.resolution || [0, 0];
1592
-
1593
- document.getElementById('panel-video').innerHTML =
1594
- detailRow('video_fps', c.video_fps) +
1595
- detailRow('frames', c.frames) +
1596
- detailRow('duration', c.duration + ' sec') +
1597
- detailRow('resolution', res[0] + ' <span class="text-slate-400 text-xs">x</span> ' + res[1]) +
1598
- detailRow('pixels', (c.pixels || 0).toLocaleString());
1599
-
1600
- const cpuPct = Math.min(100, Math.round((c.cpu_score / 10) * 100));
1601
- document.getElementById('panel-perf').innerHTML =
1602
- `<div class="flex justify-between items-center border-b border-slate-800 pb-2 relative">
1603
- <span class="text-xs font-medium text-slate-500 mono-font flex items-center">cpu_score
1604
- <span class="info-wrap"><span class="info-btn"><i class="fa-solid fa-info"></i></span>
1605
- <span class="info-tip">Available CPU core count used for throughput estimation.</span></span>
1606
- </span>
1607
- <div class="flex items-center">
1608
- <span class="text-sm font-bold text-white mr-2">${c.cpu_score}</span>
1609
- <div class="w-16 h-1.5 bg-slate-800 rounded-full overflow-hidden">
1610
- <div class="h-full bg-emerald-500" style="width:${cpuPct}%"></div>
1611
- </div>
1612
- </div>
1613
- </div>` +
1614
- infoRow('model_fps_est', c.model_fps_est, 'Estimated model inference throughput based on CPU benchmark.', ' <span class="text-xs text-slate-400 font-normal">fps</span>') +
1615
- infoRow('effective_fps', c.effective_fps_est, 'Adjusted throughput accounting for frame stride.', ' <span class="text-xs text-slate-400 font-normal">fps</span>');
1616
-
1617
- document.getElementById('panel-model').innerHTML =
1618
- `<div class="flex justify-between items-center border-b border-slate-800 pb-2">
1619
- <span class="text-xs font-medium text-slate-500 mono-font">model</span>
1620
- <a href="https://huggingface.co/Perception365/VehicleNet-Y26s" target="_blank" class="text-sm font-bold text-white mono-font hover:text-slate-300 transition underline underline-offset-4 decoration-slate-700">Perception365/VehicleNet-Y26s</a>
1621
- </div>` +
1622
- detailRow('task', 'detect') +
1623
- detailRow('format', 'OpenVINO') +
1624
- detailRow('tracker', 'ByteTrack');
1625
-
1626
- populateInferPanel(c);
1627
- }
1628
-
1629
- function populateInferPanel(c) {
1630
- document.getElementById('panel-infer').innerHTML =
1631
- infoRow('imgsz', c.imgsz, 'Input image resolution for model inference.') +
1632
- infoRow('vid_stride', c.detect_stride, 'Frames skipped between consecutive detections relative to the spatial boundary.') +
1633
- infoRow('conf', (c.conf || 0.12).toFixed(2), 'Minimum confidence threshold for valid detections.') +
1634
- infoRow('iou', (c.iou || 0.60).toFixed(2), 'Intersection-over-Union threshold for non-max suppression.') +
1635
- infoRow('stream', 'TRUE', 'Frame-by-frame processing for constant memory usage.') +
1636
- infoRow('verbose', 'FALSE', 'Console logging suppressed during inference.');
1637
- }
1638
-
1639
- // =========== Palettes ===========
1640
- const PALETTES = {
1641
- default: { congestion: '#f97316', congestionBg: 'rgba(249,115,22,0.08)', dominance: '#14b8a6', flow: '#3b82f6', doughIn: '#3b82f6', doughOut: '#f97316' },
1642
- vibrant: { congestion: '#ff2d55', congestionBg: 'rgba(255,45,85,0.08)', dominance: '#a855f7', flow: '#22d3ee', doughIn: '#22d3ee', doughOut: '#ff2d55' },
1643
- corporate: { congestion: '#1e40af', congestionBg: 'rgba(30,64,175,0.08)', dominance: '#0d9488', flow: '#475569', doughIn: '#475569', doughOut: '#1e40af' },
1644
- neon: { congestion: '#f0abfc', congestionBg: 'rgba(240,171,252,0.08)', dominance: '#22d3ee', flow: '#a855f7', doughIn: '#a855f7', doughOut: '#f0abfc' },
1645
- earth: { congestion: '#84cc16', congestionBg: 'rgba(132,204,22,0.08)', dominance: '#22c55e', flow: '#f59e0b', doughIn: '#f59e0b', doughOut: '#84cc16' },
1646
- ocean: { congestion: '#06b6d4', congestionBg: 'rgba(6,182,212,0.08)', dominance: '#3b82f6', flow: '#14b8a6', doughIn: '#14b8a6', doughOut: '#06b6d4' },
1647
- sunset: { congestion: '#f59e0b', congestionBg: 'rgba(245,158,11,0.08)', dominance: '#f43f5e', flow: '#fb923c', doughIn: '#fb923c', doughOut: '#f59e0b' },
1648
- midnight: { congestion: '#38bdf8', congestionBg: 'rgba(56,189,248,0.08)', dominance: '#1d4ed8', flow: '#f8fafc', doughIn: '#f8fafc', doughOut: '#38bdf8' },
1649
- gold: { congestion: '#fbbf24', congestionBg: 'rgba(251,191,36,0.08)', dominance: '#a8a29e', flow: '#f5f5f4', doughIn: '#f5f5f4', doughOut: '#fbbf24' }
1650
- };
1651
-
1652
- // Read settings from sessionStorage (set by settings.html)
1653
- const rawRun = sessionStorage.getItem('funky_run');
1654
- const runSettings = rawRun ? (JSON.parse(rawRun).settings || {}) : {};
1655
- let currentPalette = runSettings.palette || 'default';
1656
- let activePalette = PALETTES[currentPalette];
1657
-
1658
- // =========== Charts ===========
1659
- Chart.defaults.font.family = "'Montserrat', sans-serif";
1660
- Chart.defaults.color = '#888888';
1661
- Chart.defaults.borderColor = '#222222';
1662
- Chart.defaults.plugins.tooltip.backgroundColor = '#0a0a0a';
1663
- Chart.defaults.plugins.tooltip.titleColor = '#ffffff';
1664
- Chart.defaults.plugins.tooltip.bodyColor = '#aaaaaa';
1665
- Chart.defaults.plugins.tooltip.borderColor = '#222222';
1666
- Chart.defaults.plugins.tooltip.borderWidth = 1;
1667
-
1668
- let MODEL_CLASSES = {};
1669
- let BUSINESS_MAP = {};
1670
-
1671
- const congChart = new Chart(document.getElementById('congestionChart').getContext('2d'), {
1672
- type: 'line',
1673
- data: {
1674
- labels: [], datasets: [{
1675
- data: [],
1676
- borderColor: activePalette.congestion,
1677
- backgroundColor: activePalette.congestionBg,
1678
- fill: true,
1679
- tension: 0.2,
1680
- borderWidth: 1.5,
1681
- pointRadius: 0
1682
- }]
1683
- },
1684
- options: {
1685
- responsive: true, maintainAspectRatio: false,
1686
- plugins: { legend: { display: false } },
1687
- scales: {
1688
- x: { grid: { display: false }, ticks: { maxTicksLimit: 10, font: { size: 10 }, color: '#666666' }, title: { display: true, text: 'Frame Index', font: { size: 10, weight: '700' }, color: '#888888' } },
1689
- y: { grid: { color: '#1a1a1a' }, beginAtZero: true, ticks: { font: { size: 10 }, color: '#666666' }, title: { display: true, text: 'Active Vehicles', font: { size: 10, weight: '700' }, color: '#888888' } }
1690
- },
1691
- animation: { duration: 0 }
1692
- },
1693
- plugins: []
1694
- });
1695
-
1696
- const doughChart = new Chart(document.getElementById('doughnutChart').getContext('2d'), {
1697
- type: 'doughnut',
1698
- data: {
1699
- labels: ['Incoming', 'Outgoing'], datasets: [{
1700
- data: [0, 0],
1701
- backgroundColor: [activePalette.doughIn, activePalette.doughOut],
1702
- borderColor: '#0a0a0a',
1703
- borderWidth: 3,
1704
- hoverOffset: 6
1705
- }]
1706
- },
1707
- options: {
1708
- responsive: true, maintainAspectRatio: false,
1709
- cutout: '68%',
1710
- plugins: {
1711
- legend: { display: true, position: 'bottom', labels: { padding: 12, usePointStyle: true, pointStyle: 'circle', font: { size: 10, weight: '600' } } }
1712
- },
1713
- animation: { duration: 0 }
1714
- },
1715
- plugins: []
1716
- });
1717
-
1718
- const domChart = new Chart(document.getElementById('dominanceChart').getContext('2d'), {
1719
- type: 'bar',
1720
- data: { labels: [], datasets: [{ data: [], backgroundColor: activePalette.dominance, borderRadius: 2 }] },
1721
- options: {
1722
- responsive: true, maintainAspectRatio: false,
1723
- plugins: { legend: { display: false } },
1724
- scales: {
1725
- x: { grid: { display: false }, ticks: { font: { size: 10, weight: '500' }, color: '#666666' } },
1726
- y: { grid: { color: '#1a1a1a' }, beginAtZero: true, ticks: { font: { size: 10 }, color: '#666666' }, title: { display: true, text: 'Total Vehicle Count', font: { size: 10, weight: '700' }, color: '#888888' } }
1727
- },
1728
- animation: { duration: 0 }
1729
- },
1730
- plugins: []
1731
- });
1732
-
1733
- const flowChart = new Chart(document.getElementById('flowChart').getContext('2d'), {
1734
- type: 'bar',
1735
- data: { labels: [], datasets: [{ data: [], backgroundColor: activePalette.flow, borderColor: '#0a0a0a', borderWidth: 1.5, barPercentage: 1.0, categoryPercentage: 1.0 }] },
1736
- options: {
1737
- responsive: true, maintainAspectRatio: false,
1738
- plugins: { legend: { display: false } },
1739
- scales: {
1740
- x: { grid: { display: false }, ticks: { maxTicksLimit: 10, font: { size: 10 }, color: '#666666' }, title: { display: true, text: 'Time (seconds)', font: { size: 10, weight: '700' }, color: '#888888' } },
1741
- y: { grid: { color: '#1a1a1a' }, beginAtZero: true, ticks: { font: { size: 10 }, color: '#666666' }, title: { display: true, text: 'Vehicles Crossed', font: { size: 10, weight: '700' }, color: '#888888' } }
1742
- },
1743
- animation: { duration: 0 }
1744
- },
1745
- plugins: []
1746
- });
1747
-
1748
- // =========== Update functions ===========
1749
- function sumValues(obj) { return Object.values(obj).reduce((a, b) => a + b, 0); }
1750
-
1751
- // =========== Live Palette Switching ===========
1752
- function applyPalette(key) {
1753
- activePalette = PALETTES[key] || PALETTES.default;
1754
- currentPalette = key;
1755
-
1756
- // Congestion
1757
- congChart.data.datasets[0].borderColor = activePalette.congestion;
1758
- congChart.data.datasets[0].backgroundColor = activePalette.congestionBg;
1759
- congChart.update();
1760
-
1761
- // Doughnut
1762
- doughChart.data.datasets[0].backgroundColor = [activePalette.doughIn, activePalette.doughOut];
1763
- doughChart.update();
1764
-
1765
- // Dominance
1766
- domChart.data.datasets[0].backgroundColor = activePalette.dominance;
1767
- domChart.update();
1768
-
1769
- // Flow
1770
- flowChart.data.datasets[0].backgroundColor = activePalette.flow;
1771
- flowChart.update();
1772
-
1773
- // Update palette dropdown in progress bar
1774
- const barSel = document.getElementById('live-palette-select');
1775
- if (barSel) barSel.value = key;
1776
- }
1777
-
1778
- function renderPalettePreview(key) {
1779
- const pal = PALETTES[key];
1780
- const colors = [
1781
- { color: pal.congestion, label: 'Congestion' },
1782
- { color: pal.dominance, label: 'Dominance' },
1783
- { color: pal.flow, label: 'Flow' },
1784
- { color: pal.doughIn, label: 'Incoming' },
1785
- { color: pal.doughOut, label: 'Outgoing' }
1786
- ];
1787
- const el = document.getElementById('live-palette-preview');
1788
- if (el) {
1789
- el.innerHTML = colors.map(c =>
1790
- `<div class="flex-1 rounded-lg overflow-hidden border border-neutral-800">
1791
- <div class="h-6" style="background:${c.color}"></div>
1792
- <div class="text-[8px] font-bold text-neutral-500 text-center py-1 bg-neutral-900">${c.label}</div>
1793
- </div>`
1794
- ).join('');
1795
- }
1796
- }
1797
-
1798
- function populateSettingsTab(config, settings) {
1799
- // Populate stepper values from config
1800
- document.getElementById('sv-imgsz').textContent = config.imgsz || 640;
1801
- document.getElementById('sv-conf').textContent = (config.conf || 0.12).toFixed(2);
1802
- document.getElementById('sv-iou').textContent = (config.iou || 0.60).toFixed(2);
1803
- document.getElementById('sv-stride').textContent = config.detect_stride || 2;
1804
-
1805
- // Populate export settings
1806
- const selReport = document.getElementById('sv-report');
1807
- if (selReport) selReport.value = settings.reportFormat || 'png';
1808
- const togAnnot = document.getElementById('sv-annotated');
1809
- if (togAnnot && settings.annotatedVideo) togAnnot.classList.add('active');
1810
-
1811
- // Set live palette dropdown
1812
- const sel = document.getElementById('live-palette-select');
1813
- if (sel) sel.value = currentPalette;
1814
- renderPalettePreview(currentPalette);
1815
- }
1816
-
1817
- // =========== Settings Stepper Logic ===========
1818
- const PARAM_LIMITS = {
1819
- imgsz: { min: 640, max: 1280 },
1820
- conf: { min: 0.10, max: 0.95 },
1821
- iou: { min: 0.50, max: 0.95 },
1822
- stride: { min: 1, max: 10 },
1823
- smoothing: { min: 0.05, max: 0.95 }
1824
- };
1825
-
1826
- function stepParam(param, delta) {
1827
- const el = document.getElementById('sv-' + param);
1828
- if (!el) return;
1829
- const limits = PARAM_LIMITS[param];
1830
- let val = parseFloat(el.textContent);
1831
- val = Math.round((val + delta) * 100) / 100;
1832
- val = Math.max(limits.min, Math.min(limits.max, val));
1833
- el.textContent = (param === 'conf' || param === 'iou' || param === 'smoothing') ? val.toFixed(2) : val;
1834
- }
1835
-
1836
- function lockSettings() {
1837
- document.querySelectorAll('#settings-params .s-row').forEach(row => {
1838
- const p = row.dataset.param;
1839
- if (p && p !== 'palette') {
1840
- row.classList.add('disabled');
1841
- }
1842
- });
1843
- const reportRow = document.getElementById('sv-report');
1844
- if (reportRow) reportRow.closest('.s-row').classList.add('disabled');
1845
- const annotatedRow = document.getElementById('sv-annotated');
1846
- if (annotatedRow) annotatedRow.closest('.s-row').classList.add('disabled');
1847
- const wrap = document.getElementById('settings-start-wrap');
1848
- if (wrap) wrap.style.display = 'none';
1849
- }
1850
-
1851
- function startNewAnalysis() {
1852
- sessionStorage.clear();
1853
- _params = null;
1854
- [congChart, doughChart, domChart, flowChart].forEach(c => {
1855
- c.data.labels = [];
1856
- c.data.datasets[0].data = [];
1857
- c.update();
1858
- });
1859
- window.location.href = '/';
1860
- }
1861
- function updateBreakdown(classIn, classOut) {
1862
- const container = document.getElementById('class-breakdown');
1863
- const totalAll = sumValues(classIn) + sumValues(classOut);
1864
- container.innerHTML = '';
1865
-
1866
- // Update Doughnut border logic (remove gap if unidirectional)
1867
- const sumIn = sumValues(classIn);
1868
- const sumOut = sumValues(classOut);
1869
- doughChart.data.datasets[0].borderWidth = (sumIn === 0 || sumOut === 0) ? 0 : 3;
1870
- doughChart.data.datasets[0].data = [sumIn, sumOut];
1871
- doughChart.update();
1872
- document.getElementById('cnt-total').innerText = totalAll;
1873
-
1874
- Object.keys(MODEL_CLASSES).map(Number).sort((a, b) => a - b).forEach(id => {
1875
- const inC = classIn[String(id)] || 0;
1876
- const outC = classOut[String(id)] || 0;
1877
- const total = inC + outC;
1878
- const pct = totalAll > 0 ? ((total / totalAll) * 100).toFixed(1) : '0.0';
1879
-
1880
- const row = document.createElement('div');
1881
- row.className = 'flex items-center justify-between text-xs py-2 border-b border-slate-800';
1882
- row.innerHTML = `
1883
- <div class="w-[30%] font-bold text-white truncate" title="${MODEL_CLASSES[id]}">${MODEL_CLASSES[id]}</div>
1884
- <div class="w-[20%] text-slate-500 text-[11px]">${total} total</div>
1885
- <div class="w-[15%] text-slate-500 text-[11px]"><i class="fa-solid fa-arrow-down text-[9px] mr-1"></i>${inC}</div>
1886
- <div class="w-[15%] text-slate-500 text-[11px]"><i class="fa-solid fa-arrow-up text-[9px] mr-1"></i>${outC}</div>
1887
- <div class="w-[20%] text-right font-bold text-white">${pct}%</div>
1888
- `;
1889
- container.appendChild(row);
1890
- });
1891
- }
1892
-
1893
- function updateDominance(classIn, classOut) {
1894
- const labels = [], values = [];
1895
- for (const [group, ids] of Object.entries(BUSINESS_MAP)) {
1896
- let total = 0;
1897
- ids.forEach(id => { total += (classIn[String(id)] || 0) + (classOut[String(id)] || 0); });
1898
- labels.push(group);
1899
- values.push(total);
1900
- }
1901
- domChart.data.labels = labels;
1902
- domChart.data.datasets[0].data = values;
1903
- domChart.update();
1904
- }
1905
-
1906
- function buildFlowHistogram(flowTimes, videoDuration) {
1907
- const binCount = Math.max(1, Math.ceil(videoDuration));
1908
- const bins = new Array(binCount).fill(0);
1909
- const labels = [];
1910
- for (let i = 0; i < binCount; i++) labels.push(i);
1911
- flowTimes.forEach(t => { bins[Math.min(Math.floor(t), binCount - 1)]++; });
1912
- flowChart.data.labels = labels;
1913
- flowChart.data.datasets[0].data = bins;
1914
- flowChart.update();
1915
- }
1916
-
1917
- let _alpha = 0.25;
1918
-
1919
- function updateCongestion(congestion, stride) {
1920
- let data = congestion;
1921
- // Apply EMA smoothing if alpha is less than 1 (1 = no smoothing)
1922
- if (_alpha < 0.99) {
1923
- data = [];
1924
- let s = congestion[0] || 0;
1925
- for (let v of congestion) {
1926
- s = _alpha * v + (1 - _alpha) * s;
1927
- data.push(s);
1928
- }
1929
- }
1930
-
1931
- const len = data.length;
1932
- if (len <= 200) {
1933
- congChart.data.labels = data.map((_, i) => i * stride);
1934
- congChart.data.datasets[0].data = data;
1935
- } else {
1936
- // Dynamic sampling to keep chart performance high
1937
- const step = Math.ceil(len / 200);
1938
- const sampled = [], labels = [];
1939
- for (let i = 0; i < len; i += step) {
1940
- labels.push(i * stride);
1941
- sampled.push(data[i]);
1942
- }
1943
- congChart.data.labels = labels;
1944
- congChart.data.datasets[0].data = sampled;
1945
- }
1946
- congChart.update();
1947
- }
1948
-
1949
- // =========== Main ===========
1950
- let _params = null;
1951
-
1952
- async function init() {
1953
- const raw = sessionStorage.getItem('funky_run');
1954
- if (!raw) { window.location.href = '/'; return; }
1955
-
1956
- _params = JSON.parse(raw);
1957
-
1958
- // SECURITY: Clear session storage so refresh always redirects home
1959
- sessionStorage.removeItem('funky_run');
1960
-
1961
- const cRes = await fetch('/constants');
1962
- const cData = await cRes.json();
1963
- MODEL_CLASSES = cData.classes;
1964
- BUSINESS_MAP = cData.business_map;
1965
-
1966
- populateAndInit(_params);
1967
-
1968
- // SECURITY: Clear session storage after populate so refresh triggers redirect
1969
- sessionStorage.removeItem('funky_run');
1970
-
1971
- // Show Settings tab first, but also initialized with About implicitly in sidebar hierarchy
1972
- switchTab('settings');
1973
- }
1974
-
1975
- function populateAndInit(params) {
1976
- populateRunDetails(params.config);
1977
- populateSettingsTab(params.config, params.settings || {});
1978
- }
1979
-
1980
- function startProcessingFromSettings() {
1981
- if (!_params) return;
1982
-
1983
- // Read current stepper/control values
1984
- const imgsz = parseInt(document.getElementById('sv-imgsz').textContent);
1985
- const conf = parseFloat(document.getElementById('sv-conf').textContent);
1986
- const iou = parseFloat(document.getElementById('sv-iou').textContent);
1987
- const stride = parseInt(document.getElementById('sv-stride').textContent);
1988
- const reportFmt = document.getElementById('sv-report').value;
1989
- const annotated = document.getElementById('sv-annotated').classList.contains('active');
1990
- _alpha = parseFloat(document.getElementById('sv-smoothing').textContent) || 0.25;
1991
-
1992
- // Annotation Options
1993
- const annotated_options = {
1994
- bbox: true, // Always true if export is enabled
1995
- spatial: document.getElementById('chip-spatial').classList.contains('active'),
1996
- class_name: document.getElementById('chip-class_name').classList.contains('active'),
1997
- class_id: document.getElementById('chip-class_id').classList.contains('active'),
1998
- track_id: document.getElementById('chip-track_id').classList.contains('active')
1999
- };
2000
-
2001
- const exportJson = document.getElementById('sv-export-json').classList.contains('active');
2002
- const exportCsv = document.getElementById('sv-export-csv').classList.contains('active');
2003
-
2004
- // Apply to config
2005
- _params.config.imgsz = imgsz;
2006
- _params.config.conf = conf;
2007
- _params.config.iou = iou;
2008
- _params.config.detect_stride = stride;
2009
-
2010
- // Reflect final resolved params in Run tab
2011
- populateInferPanel(_params.config);
2012
-
2013
- // Lock settings
2014
- lockSettings();
2015
-
2016
- // Switch to overview
2017
- switchTab('overview');
2018
- document.getElementById('proc-label').innerText = 'Processing';
2019
-
2020
- // Reset Run Tab Results to Awaiting
2021
- const analyzeAgainBtn = document.getElementById('run-analyze-again-btn');
2022
- if (analyzeAgainBtn) {
2023
- analyzeAgainBtn.classList.add('hidden');
2024
- }
2025
-
2026
- const badge = document.getElementById('results-status-badge');
2027
- if (badge) {
2028
- badge.innerText = 'Processing';
2029
- badge.className = 'px-2.5 py-1 bg-slate-800 text-white text-[10px] font-bold rounded-full uppercase tracking-tighter animate-pulse';
2030
- }
2031
- document.getElementById('run-results-content').innerHTML = `
2032
- <div class="flex flex-col items-center justify-center p-8 bg-black/40 border border-slate-800 rounded-2xl col-span-3 text-slate-500">
2033
- <i class="fa-solid fa-spinner fa-spin text-2xl mb-3 text-white"></i>
2034
- <span class="text-xs font-semibold">Executing inference pipeline... results pending</span>
2035
- </div>`;
2036
-
2037
- // Update Reports tab pending message
2038
- const repIcon = document.getElementById('reports-pending-icon');
2039
- if (repIcon) repIcon.className = 'fa-solid fa-circle-notch fa-spin text-[#c89a6c]';
2040
- const repText = document.getElementById('reports-pending-text');
2041
- if (repText) repText.innerText = 'Generating artifacts & rendering analytics... Please wait';
2042
-
2043
-
2044
- // Start WebSocket
2045
- const videoDuration = _params.config.duration || 10;
2046
-
2047
- const proto = location.protocol === 'https:' ? 'wss' : 'ws';
2048
- const ws = new WebSocket(`${proto}://${location.host}/ws/run`);
2049
-
2050
- ws.onopen = () => {
2051
- ws.send(JSON.stringify({
2052
- video_id: _params.video_id,
2053
- line: _params.line,
2054
- config: _params.config,
2055
- annotated_video: annotated,
2056
- annotated_options: annotated_options,
2057
- export_json: exportJson,
2058
- export_csv: exportCsv,
2059
- report_format: reportFmt
2060
- }));
2061
- };
2062
-
2063
- ws.onerror = e => {
2064
- console.error('WS Error:', e);
2065
- document.getElementById('proc-label').innerText = 'Connection Error';
2066
- showToast('Connection error — server may be busy', 'error');
2067
- if (badge) {
2068
- badge.innerText = 'Pipeline Failed';
2069
- badge.className = 'px-2.5 py-1 bg-red-900/40 text-red-100 text-[10px] font-bold rounded-full uppercase tracking-tighter';
2070
- }
2071
- };
2072
-
2073
- let processingDone = false;
2074
-
2075
- ws.onclose = () => {
2076
- console.log('WS Closed');
2077
- if (!processingDone) {
2078
- // Closed before done=True received — show error state
2079
- document.getElementById('proc-label').innerText = 'Disconnected';
2080
- const badge = document.getElementById('results-status-badge');
2081
- if (badge) {
2082
- badge.innerText = 'Connection Lost';
2083
- badge.className = 'px-2.5 py-1 bg-red-900/40 text-red-300 text-[10px] font-bold rounded-full uppercase tracking-tighter';
2084
- }
2085
- document.getElementById('run-results-content').innerHTML = `
2086
- <div class="flex flex-col items-center justify-center p-8 border-2 border-dashed border-red-900/40 rounded-2xl col-span-3 text-slate-400">
2087
- <i class="fa-solid fa-triangle-exclamation text-2xl mb-3 text-red-400"></i>
2088
- <span class="text-xs font-semibold mb-1">Processing connection was lost.</span>
2089
- <span class="text-[10px] text-slate-500 mb-4">The server may have timed out or restarted. Please try again.</span>
2090
- <button onclick="startNewAnalysis()" class="text-[10px] font-bold uppercase tracking-widest px-4 py-2 rounded-full" style="background:#111;border:1px solid #2a2a2a;color:#c89a6c">
2091
- &larr; Start New Analysis
2092
- </button>
2093
- </div>`;
2094
- }
2095
- };
2096
-
2097
- let lastUIUpdate = 0;
2098
-
2099
- ws.onmessage = e => {
2100
- const d = JSON.parse(e.data);
2101
-
2102
- // Hide empty state on first data
2103
- const emptyState = document.getElementById('stats-empty-state');
2104
- if (emptyState) emptyState.style.display = 'none';
2105
- if (d.error) {
2106
- processingDone = true;
2107
- document.getElementById('proc-label').innerText = 'Engine Error';
2108
- const badge = document.getElementById('results-status-badge');
2109
- if (badge) {
2110
- badge.innerText = 'Failed';
2111
- badge.className = 'px-2.5 py-1 bg-red-900/40 text-red-300 text-[10px] font-bold rounded-full uppercase tracking-tighter';
2112
- }
2113
- console.error('[UrbanFlow] Engine error:', d.detail || d.error);
2114
- document.getElementById('run-results-content').innerHTML = `
2115
- <div class="flex flex-col items-center justify-center p-8 border-2 border-dashed border-red-900/40 rounded-2xl col-span-3 text-slate-400">
2116
- <i class="fa-solid fa-triangle-exclamation text-2xl mb-3 text-red-400"></i>
2117
- <span class="text-xs font-semibold mb-1">Inference pipeline failed.</span>
2118
- <span class="text-[10px] text-slate-500 mb-4 text-center max-w-xs">${d.error}</span>
2119
- <button onclick="startNewAnalysis()" class="text-[10px] font-bold uppercase tracking-widest px-4 py-2 rounded-full" style="background:#111;border:1px solid #2a2a2a;color:#c89a6c">
2120
- &larr; Start New Analysis
2121
- </button>
2122
- </div>`;
2123
- return;
2124
- }
2125
-
2126
- if (d.done) {
2127
- processingDone = true;
2128
- document.getElementById('proc-label').innerText = 'Complete';
2129
- document.getElementById('proc-bar').style.width = '100%';
2130
- document.getElementById('proc-pct').innerText = '100%';
2131
- // Force frame counter to n/n
2132
- const framesEl = document.getElementById('proc-frames');
2133
- if (framesEl) {
2134
- const parts = framesEl.innerText.split('/');
2135
- if (parts.length === 2) {
2136
- const total = parts[1].trim().replace(' Frames', '');
2137
- framesEl.innerText = `${total} / ${total} Frames`;
2138
- }
2139
- }
2140
-
2141
- // Update Run Tab Badge
2142
- const badge = document.getElementById('results-status-badge');
2143
- if (badge) {
2144
- badge.innerText = 'Completed';
2145
- badge.className = 'px-2.5 py-1 bg-white text-black text-[10px] font-bold rounded-full uppercase tracking-tighter';
2146
- }
2147
-
2148
- const analyzeAgainBtn = document.getElementById('run-analyze-again-btn');
2149
- if (analyzeAgainBtn) {
2150
- analyzeAgainBtn.classList.remove('hidden');
2151
- }
2152
-
2153
- document.getElementById('run-results-content').innerHTML =
2154
- detailRow('Inference Time', d.processing_time + ' sec') +
2155
- infoRow('Throughput (FPS)', d.actual_fps, 'Measured frame throughput during processing.', ' <span class="text-xs text-slate-400 font-normal">fps</span>') +
2156
- infoRow('Real-time Ratio', d.speed_vs_realtime + 'x', 'Processing speed relative to video playback rate.');
2157
-
2158
- if (d.video_id) {
2159
- loadReports(d.video_id).then(data => {
2160
- if (!data) return;
2161
-
2162
- // Auto-Download Logic (Respects live toggle state)
2163
- if (document.getElementById('sv-auto-download').classList.contains('active')) {
2164
- // Download the full bundle ZIP via direct navigation
2165
- setTimeout(() => {
2166
- console.log('[UrbanFlow] Fetching ZIP bundle for:', d.video_id);
2167
- window.open(`/bundle/${d.video_id}`, '_blank');
2168
- }, 1000);
2169
- }
2170
- });
2171
- }
2172
-
2173
- // Disable Auto-Download toggle after completion
2174
- const adToggle = document.getElementById('sv-auto-download');
2175
- if (adToggle) {
2176
- adToggle.closest('.s-row').classList.add('disabled');
2177
- }
2178
- const jsonToggle = document.getElementById('sv-export-json');
2179
- if (jsonToggle) {
2180
- jsonToggle.closest('.s-row').classList.add('disabled');
2181
- }
2182
- const csvToggle = document.getElementById('sv-export-csv');
2183
- if (csvToggle) {
2184
- csvToggle.closest('.s-row').classList.add('disabled');
2185
- }
2186
-
2187
- // Show New Analysis button in Settings
2188
- const newWrap = document.getElementById('new-analysis-wrap');
2189
- if (newWrap) newWrap.classList.remove('hidden');
2190
-
2191
- // Toast + Insights
2192
- showToast('Processing complete — artifacts ready', 'success');
2193
- renderInsights(d);
2194
-
2195
- // Store video_id for keyboard shortcut download
2196
- document.body.setAttribute('data-last-video-id', d.video_id);
2197
-
2198
- return;
2199
- }
2200
-
2201
- let pct = ((d.frame_index / d.total_iters) * 100).toFixed(1);
2202
- if (d.frame_index >= d.total_iters - 1) pct = '100.0';
2203
- document.getElementById('proc-bar').style.width = pct + '%';
2204
- document.getElementById('proc-frames').innerText = `${d.frame_index} / ${d.total_iters} Frames`;
2205
-
2206
- const procPctEl = document.getElementById('proc-pct');
2207
- const currPct = parseFloat(procPctEl.innerText) || 0;
2208
- animateValue(procPctEl, currPct, pct + '%', 300);
2209
-
2210
- const totalIn = sumValues(d.class_in);
2211
- const totalOut = sumValues(d.class_out);
2212
-
2213
- const cntTotalEl = document.getElementById('cnt-total');
2214
- const currTotal = parseInt(cntTotalEl.innerText) || 0;
2215
- animateValue(cntTotalEl, currTotal, totalIn + totalOut, 300);
2216
-
2217
- // Update PCU display
2218
- const pcuVal = calcPCU(d.class_in, d.class_out);
2219
- const pcuEl = document.getElementById('cnt-pcu');
2220
- if (pcuEl) pcuEl.innerText = pcuVal;
2221
-
2222
- // Update doughnut
2223
- doughChart.data.datasets[0].data = [totalIn, totalOut];
2224
- doughChart.update();
2225
-
2226
- const now = performance.now();
2227
- if (now - lastUIUpdate < 300) return;
2228
- lastUIUpdate = now;
2229
-
2230
- updateCongestion(d.congestion, stride);
2231
- updateBreakdown(d.class_in, d.class_out);
2232
- updateDominance(d.class_in, d.class_out);
2233
- buildFlowHistogram(d.flow_times, videoDuration);
2234
- };
2235
- }
2236
-
2237
- const REPORT_LABELS = {
2238
- 'direction_pie.png': { title: 'Directional Split', desc: 'Incoming vs outgoing vehicle ratio' },
2239
- 'direction_pie.pdf': { title: 'Directional Split', desc: 'Incoming vs outgoing vehicle ratio' },
2240
- 'flow_over_time.png': { title: 'Traffic Flow', desc: 'Vehicles crossing the line over time' },
2241
- 'flow_over_time.pdf': { title: 'Traffic Flow', desc: 'Vehicles crossing the line over time' },
2242
- 'congestion_index.png': { title: 'Congestion Index', desc: 'Active vehicles per frame with smoothing' },
2243
- 'congestion_index.pdf': { title: 'Congestion Index', desc: 'Active vehicles per frame with smoothing' },
2244
- 'class_dominance.png': { title: 'Class Dominance', desc: 'Vehicle count by classification type' },
2245
- 'class_dominance.pdf': { title: 'Class Dominance', desc: 'Vehicle count by classification type' },
2246
- 'confidence_dist.png': { title: 'Confidence Curve', desc: 'Distribution of detection confidences' },
2247
- 'confidence_dist.pdf': { title: 'Confidence Curve', desc: 'Distribution of detection confidences' },
2248
- 'annotated.mp4': { title: 'Annotated Video Export', desc: 'Rendered video with tracking overlays' },
2249
- 'heatmap.png': { title: 'Spatial Density Heatmap', desc: 'Aggregated path utilization mapping' },
2250
- 'heatmap.pdf': { title: 'Spatial Density Heatmap', desc: 'Aggregated path utilization mapping' },
2251
- 'raw_data.csv': { title: 'Raw Analytics Export', desc: 'Comma-separated values of all crossings' },
2252
- 'analysis.json': { title: 'Structured JSON Export', desc: 'Complete analysis data with metadata for API consumption' }
2253
- };
2254
-
2255
- async function loadReports(videoId) {
2256
- const res = await fetch(`/reports/${videoId}`, { method: 'POST' });
2257
- const data = await res.json();
2258
- if (!data.files || !data.files.length) return null;
2259
-
2260
- document.getElementById('reports-pending').classList.add('hidden');
2261
- document.getElementById('reports-pending-message').classList.add('hidden');
2262
- document.getElementById('post-process-cards').classList.remove('hidden');
2263
- const grid = document.getElementById('reports-grid');
2264
- grid.classList.remove('hidden');
2265
- grid.innerHTML = '';
2266
-
2267
- data.files.forEach(name => {
2268
- const info = REPORT_LABELS[name] || { title: name, desc: '' };
2269
- const url = `/reports/${videoId}/${name}`;
2270
- const isVideo = name.endsWith('.mp4');
2271
- const isPDF = name.endsWith('.pdf');
2272
- const isCSV = name.endsWith('.csv');
2273
- const card = document.createElement('div');
2274
- card.className = 'bg-black rounded-xl border border-slate-800 shadow-sm flex flex-col overflow-hidden';
2275
-
2276
- let previewHTML = '';
2277
- if (isVideo) {
2278
- previewHTML = `
2279
- <div class="flex flex-col items-center justify-center py-12 text-slate-700">
2280
- <i class="fa-solid fa-film text-6xl mb-4 text-white"></i>
2281
- <span class="text-xs font-bold uppercase tracking-widest text-slate-500">Video Ready for Local Analysis</span>
2282
- </div>`;
2283
- } else if (isPDF) {
2284
- previewHTML = `
2285
- <div class="flex flex-col items-center justify-center py-12 text-slate-700">
2286
- <i class="fa-solid fa-file-pdf text-6xl mb-4 text-white"></i>
2287
- <span class="text-xs font-bold uppercase tracking-widest text-slate-500">PDF Document</span>
2288
- </div>`;
2289
- } else if (isCSV) {
2290
- previewHTML = `
2291
- <div class="flex flex-col items-center justify-center py-12 text-slate-700">
2292
- <i class="fa-solid fa-file-csv text-6xl mb-4 text-white"></i>
2293
- <span class="text-xs font-bold uppercase tracking-widest text-slate-500">Raw Analytics Export</span>
2294
- </div>`;
2295
- } else if (name.endsWith('.json')) {
2296
- previewHTML = `
2297
- <div class="flex flex-col items-center justify-center py-12 text-slate-700">
2298
- <i class="fa-solid fa-code text-6xl mb-4 text-white"></i>
2299
- <span class="text-xs font-bold uppercase tracking-widest text-slate-500">Structured JSON</span>
2300
- </div>`;
2301
- } else {
2302
- previewHTML = `<img src="${url}" alt="${info.title}" class="max-w-full max-h-[320px] object-contain rounded">`;
2303
- }
2304
-
2305
- card.innerHTML = `
2306
- <div class="px-5 py-3 border-b border-slate-800 bg-slate-900/40 flex justify-between items-center">
2307
- <div>
2308
- <h3 class="font-bold text-white text-sm">${info.title}</h3>
2309
- <p class="text-[10px] text-slate-400 mt-0.5">${info.desc}</p>
2310
- </div>
2311
- <a href="${url}" download="${name}"
2312
- class="inline-flex items-center gap-1.5 px-3 py-1.5 border border-[#444444] text-white text-[10px] font-bold rounded-full hover:bg-neutral-800 transition uppercase tracking-wider">
2313
- <i class="fa-solid fa-download text-[9px]"></i> Download
2314
- </a>
2315
- </div>
2316
- <div class="p-4 flex items-center justify-center bg-black/30">
2317
- ${previewHTML}
2318
- </div>
2319
- `;
2320
- grid.appendChild(card);
2321
- });
2322
- return data;
2323
- }
2324
-
2325
- function toggleExportMaster(el) {
2326
- el.classList.toggle('active');
2327
- const chips = document.getElementById('chip-selector');
2328
- if (el.classList.contains('active')) {
2329
- chips.classList.remove('hidden-chip-container');
2330
- } else {
2331
- chips.classList.add('hidden-chip-container');
2332
- }
2333
- }
2334
-
2335
- function toggleAutoDownload(el) {
2336
- el.classList.toggle('active');
2337
- }
2338
-
2339
- function toggleChip(id) {
2340
- const chip = document.getElementById(`chip-${id}`);
2341
- chip.classList.toggle('active');
2342
- const icon = chip.querySelector('i');
2343
- if (chip.classList.contains('active')) {
2344
- icon.className = 'fa-solid fa-check';
2345
- } else {
2346
- icon.className = 'fa-solid fa-plus';
2347
- }
2348
- }
2349
-
2350
- init();
2351
- </script>
2352
  <!-- Privacy Modal -->
2353
  <div id="appModal-privacyModal" onclick="if(event.target===this)closeAppModal('privacyModal')"
2354
  style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.85);z-index:9999;align-items:center;justify-content:center;padding:24px">
 
5
  <meta charset="UTF-8">
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
  <title>UrbanFlow</title>
8
+ <link rel="icon" type="image/svg+xml" href="/assets/rf.png">
9
  <script src="https://cdn.tailwindcss.com"></script>
10
  <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
11
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
 
13
  href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Montserrat:wght@400;500;600;700;800;900&display=swap"
14
  rel="stylesheet">
15
  <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
16
+ <link rel="stylesheet" href="/css/vehicles.css">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  </head>
18
 
19
  <body class="bg-black text-white h-screen w-screen overflow-hidden flex">
 
21
  <!-- Sidebar -->
22
  <aside class="w-60 bg-white shadow-xl flex flex-col z-20 flex-shrink-0 border-r border-slate-200 relative">
23
  <div class="h-28 bg-black flex items-center justify-center px-4 my-2 border-b border-slate-800 flex-shrink-0">
24
+ <img id="sidebar-logo-top" src="/assets/uf_rf.png" alt="UrbanFlow Logo" class="h-24 w-auto object-contain">
25
  </div>
26
  <nav class="flex-1 px-4 py-4 space-y-1.5 overflow-y-auto text-sm">
27
  <a onclick="switchTab('about')" id="nav-about"
 
65
 
66
  <!-- Mobile Navigation (hidden on desktop) -->
67
  <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">
68
+ <img src="/assets/uf_rf.png" alt="UF" class="h-8">
69
  <div class="flex gap-1">
70
  <button onclick="switchTab('about')" class="text-[9px] px-2 py-1 rounded" style="color:#a89f97"><i class="fa-solid fa-circle-info"></i></button>
71
  <button onclick="switchTab('overview')" class="text-[9px] px-2 py-1 rounded" style="color:#a89f97"><i class="fa-solid fa-desktop"></i></button>
 
842
 
843
  </main>
844
 
845
+ <script src="/js/vehicles.js"></script>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
846
  <!-- Privacy Modal -->
847
  <div id="appModal-privacyModal" onclick="if(event.target===this)closeAppModal('privacyModal')"
848
  style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.85);z-index:9999;align-items:center;justify-content:center;padding:24px">