waze-logs 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1241 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Waze Global Traffic Tracker</title>
7
+
8
+ <!-- Favicon -->
9
+ <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🌍</text></svg>">
10
+
11
+ <!-- Leaflet CSS -->
12
+ <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
13
+
14
+ <style>
15
+ * {
16
+ margin: 0;
17
+ padding: 0;
18
+ box-sizing: border-box;
19
+ }
20
+
21
+ body {
22
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
23
+ background: #0a0a0a;
24
+ color: #eee;
25
+ }
26
+
27
+ .container {
28
+ display: flex;
29
+ height: 100vh;
30
+ }
31
+
32
+ /* Sidebar */
33
+ .sidebar {
34
+ width: 280px;
35
+ background: rgba(10, 10, 10, 0.95);
36
+ padding: 15px;
37
+ overflow-y: auto;
38
+ border-right: 1px solid #1a1a1a;
39
+ transition: transform 0.3s ease, width 0.3s ease;
40
+ position: relative;
41
+ z-index: 1001;
42
+ display: flex;
43
+ flex-direction: column;
44
+ }
45
+
46
+ .sidebar.collapsed {
47
+ transform: translateX(-280px);
48
+ width: 0;
49
+ padding: 0;
50
+ overflow: hidden;
51
+ }
52
+
53
+ .sidebar-toggle {
54
+ position: absolute;
55
+ left: 280px;
56
+ top: 15px;
57
+ width: 36px;
58
+ height: 36px;
59
+ background: rgba(10, 10, 10, 0.9);
60
+ border: 1px solid #333;
61
+ border-left: none;
62
+ border-radius: 0 6px 6px 0;
63
+ color: #ff6b35;
64
+ cursor: pointer;
65
+ z-index: 1002;
66
+ display: flex;
67
+ align-items: center;
68
+ justify-content: center;
69
+ font-size: 1.1rem;
70
+ transition: left 0.3s ease;
71
+ }
72
+
73
+ .sidebar.collapsed + .sidebar-toggle,
74
+ .sidebar.collapsed ~ .sidebar-toggle {
75
+ left: 0;
76
+ }
77
+
78
+ .sidebar h1 {
79
+ font-size: 1.1rem;
80
+ color: #ff6b35;
81
+ margin-bottom: 3px;
82
+ font-weight: 600;
83
+ }
84
+
85
+ .sidebar .subtitle {
86
+ font-size: 0.7rem;
87
+ color: #555;
88
+ margin-bottom: 15px;
89
+ text-transform: uppercase;
90
+ letter-spacing: 1px;
91
+ }
92
+
93
+ /* Panels */
94
+ .panel {
95
+ background: rgba(255, 107, 53, 0.03);
96
+ border: 1px solid rgba(255, 107, 53, 0.15);
97
+ border-radius: 6px;
98
+ padding: 12px;
99
+ margin-bottom: 12px;
100
+ }
101
+
102
+ .panel h3 {
103
+ font-size: 0.7rem;
104
+ color: #ff6b35;
105
+ margin-bottom: 10px;
106
+ text-transform: uppercase;
107
+ letter-spacing: 1px;
108
+ }
109
+
110
+ .stat-row {
111
+ display: flex;
112
+ justify-content: space-between;
113
+ padding: 5px 0;
114
+ border-bottom: 1px solid rgba(255, 255, 255, 0.03);
115
+ font-size: 0.8rem;
116
+ }
117
+
118
+ .stat-row:last-child {
119
+ border-bottom: none;
120
+ }
121
+
122
+ .stat-label { color: #666; }
123
+ .stat-value { font-weight: 600; color: #ff8c5a; }
124
+
125
+ /* Filters */
126
+ .filter-group {
127
+ margin-bottom: 10px;
128
+ }
129
+
130
+ .filter-group label {
131
+ display: block;
132
+ font-size: 0.75rem;
133
+ color: #666;
134
+ margin-bottom: 4px;
135
+ }
136
+
137
+ .filter-group select,
138
+ .filter-group input[type="date"] {
139
+ width: 100%;
140
+ padding: 6px 8px;
141
+ background: #151515;
142
+ border: 1px solid #333;
143
+ border-radius: 4px;
144
+ color: #eee;
145
+ font-size: 0.8rem;
146
+ }
147
+
148
+ .filter-group select:focus,
149
+ .filter-group input:focus {
150
+ outline: none;
151
+ border-color: #ff6b35;
152
+ }
153
+
154
+ /* Type filters */
155
+ .type-filters {
156
+ display: flex;
157
+ flex-wrap: wrap;
158
+ gap: 4px;
159
+ margin-top: 6px;
160
+ }
161
+
162
+ .type-chip {
163
+ padding: 4px 8px;
164
+ background: #1a1a1a;
165
+ border: 1px solid #333;
166
+ border-radius: 12px;
167
+ font-size: 0.7rem;
168
+ cursor: pointer;
169
+ transition: all 0.2s;
170
+ display: flex;
171
+ align-items: center;
172
+ gap: 4px;
173
+ }
174
+
175
+ .type-chip:hover {
176
+ border-color: #ff6b35;
177
+ }
178
+
179
+ .type-chip.active {
180
+ background: rgba(255, 107, 53, 0.2);
181
+ border-color: #ff6b35;
182
+ color: #ff8c5a;
183
+ }
184
+
185
+ .type-chip .dot {
186
+ width: 6px;
187
+ height: 6px;
188
+ border-radius: 50%;
189
+ }
190
+
191
+ /* Buttons */
192
+ .btn {
193
+ padding: 6px 12px;
194
+ background: #ff6b35;
195
+ border: none;
196
+ border-radius: 4px;
197
+ color: #0a0a0a;
198
+ cursor: pointer;
199
+ font-size: 0.75rem;
200
+ font-weight: 600;
201
+ transition: background 0.2s;
202
+ }
203
+
204
+ .btn:hover { background: #ff8c5a; }
205
+
206
+ .btn-small {
207
+ padding: 4px 8px;
208
+ font-size: 0.7rem;
209
+ }
210
+
211
+ .btn-ghost {
212
+ background: transparent;
213
+ border: 1px solid #333;
214
+ color: #888;
215
+ }
216
+
217
+ .btn-ghost:hover {
218
+ border-color: #ff6b35;
219
+ color: #ff6b35;
220
+ background: rgba(255, 107, 53, 0.1);
221
+ }
222
+
223
+ /* Checkbox */
224
+ .checkbox-label {
225
+ display: flex;
226
+ align-items: center;
227
+ cursor: pointer;
228
+ font-size: 0.8rem;
229
+ color: #888;
230
+ gap: 6px;
231
+ }
232
+
233
+ .checkbox-label input {
234
+ accent-color: #ff6b35;
235
+ }
236
+
237
+ /* Live Feed */
238
+ .live-feed {
239
+ flex: 1;
240
+ min-height: 150px;
241
+ max-height: 250px;
242
+ overflow: hidden;
243
+ display: flex;
244
+ flex-direction: column;
245
+ }
246
+
247
+ .feed-header {
248
+ display: flex;
249
+ justify-content: space-between;
250
+ align-items: center;
251
+ margin-bottom: 8px;
252
+ }
253
+
254
+ .feed-header h3 {
255
+ margin: 0;
256
+ }
257
+
258
+ .live-indicator {
259
+ display: flex;
260
+ align-items: center;
261
+ gap: 4px;
262
+ font-size: 0.65rem;
263
+ color: #666;
264
+ }
265
+
266
+ .live-dot {
267
+ width: 6px;
268
+ height: 6px;
269
+ border-radius: 50%;
270
+ background: #ff6b35;
271
+ animation: pulse 2s infinite;
272
+ }
273
+
274
+ @keyframes pulse {
275
+ 0%, 100% { opacity: 1; }
276
+ 50% { opacity: 0.3; }
277
+ }
278
+
279
+ .feed-items {
280
+ flex: 1;
281
+ overflow-y: auto;
282
+ font-size: 0.7rem;
283
+ }
284
+
285
+ .feed-item {
286
+ padding: 6px 8px;
287
+ margin-bottom: 4px;
288
+ background: rgba(255, 107, 53, 0.05);
289
+ border-radius: 4px;
290
+ border-left: 2px solid #ff6b35;
291
+ animation: slideIn 0.3s ease;
292
+ }
293
+
294
+ .feed-item.clickable {
295
+ cursor: pointer;
296
+ transition: background 0.2s;
297
+ }
298
+
299
+ .feed-item.clickable:hover {
300
+ background: rgba(255, 107, 53, 0.2);
301
+ }
302
+
303
+ .feed-item.new {
304
+ animation: highlight 1s ease;
305
+ }
306
+
307
+ @keyframes slideIn {
308
+ from { opacity: 0; transform: translateX(-10px); }
309
+ to { opacity: 1; transform: translateX(0); }
310
+ }
311
+
312
+ @keyframes highlight {
313
+ 0% { background: rgba(255, 107, 53, 0.3); }
314
+ 100% { background: rgba(255, 107, 53, 0.05); }
315
+ }
316
+
317
+ .feed-item .type {
318
+ color: #ff8c5a;
319
+ font-weight: 600;
320
+ }
321
+
322
+ .feed-item .location {
323
+ color: #888;
324
+ }
325
+
326
+ .feed-item .time {
327
+ color: #555;
328
+ font-size: 0.65rem;
329
+ }
330
+
331
+ /* Map Container */
332
+ .map-container {
333
+ flex: 1;
334
+ position: relative;
335
+ }
336
+
337
+ #map {
338
+ width: 100%;
339
+ height: 100%;
340
+ background: #0a0a0a;
341
+ }
342
+
343
+ /* Status Bar */
344
+ .status-bar {
345
+ position: absolute;
346
+ top: 12px;
347
+ right: 12px;
348
+ background: rgba(10, 10, 10, 0.9);
349
+ border: 1px solid #333;
350
+ padding: 6px 12px;
351
+ border-radius: 16px;
352
+ z-index: 1000;
353
+ font-size: 0.7rem;
354
+ color: #888;
355
+ display: flex;
356
+ align-items: center;
357
+ gap: 6px;
358
+ max-width: 350px;
359
+ }
360
+
361
+ .status-dot {
362
+ width: 6px;
363
+ height: 6px;
364
+ border-radius: 50%;
365
+ background: #ff6b35;
366
+ animation: pulse 2s infinite;
367
+ flex-shrink: 0;
368
+ }
369
+
370
+ #status-text {
371
+ white-space: nowrap;
372
+ overflow: hidden;
373
+ text-overflow: ellipsis;
374
+ }
375
+
376
+ /* Legend */
377
+ .legend {
378
+ position: absolute;
379
+ bottom: 25px;
380
+ right: 12px;
381
+ background: rgba(10, 10, 10, 0.9);
382
+ border: 1px solid #333;
383
+ padding: 10px 12px;
384
+ border-radius: 6px;
385
+ z-index: 1000;
386
+ font-size: 0.7rem;
387
+ }
388
+
389
+ .legend h4 {
390
+ color: #ff6b35;
391
+ margin-bottom: 6px;
392
+ font-size: 0.7rem;
393
+ text-transform: uppercase;
394
+ letter-spacing: 1px;
395
+ }
396
+
397
+ .legend-gradient {
398
+ width: 100px;
399
+ height: 10px;
400
+ background: linear-gradient(to right, #2d1000, #8b3a00, #ff6b35, #ff8c5a);
401
+ border-radius: 2px;
402
+ margin-bottom: 4px;
403
+ }
404
+
405
+ .legend-labels {
406
+ display: flex;
407
+ justify-content: space-between;
408
+ color: #555;
409
+ font-size: 0.65rem;
410
+ }
411
+
412
+ /* Popup styling */
413
+ .event-popup h4 {
414
+ color: #ff6b35;
415
+ margin-bottom: 6px;
416
+ }
417
+
418
+ .event-popup .detail {
419
+ font-size: 0.85rem;
420
+ color: #333;
421
+ margin: 3px 0;
422
+ }
423
+
424
+ .event-popup .detail strong {
425
+ color: #1a1a1a;
426
+ }
427
+
428
+ /* Leaderboard */
429
+ .leaderboard-panel {
430
+ min-height: 120px;
431
+ }
432
+
433
+ .leaderboard {
434
+ max-height: 150px;
435
+ overflow-y: auto;
436
+ }
437
+
438
+ .leaderboard-item {
439
+ display: flex;
440
+ align-items: center;
441
+ padding: 6px 8px;
442
+ margin-bottom: 4px;
443
+ background: rgba(255, 107, 53, 0.03);
444
+ border-radius: 4px;
445
+ font-size: 0.75rem;
446
+ cursor: pointer;
447
+ transition: background 0.2s;
448
+ }
449
+
450
+ .leaderboard-item:hover {
451
+ background: rgba(255, 107, 53, 0.1);
452
+ }
453
+
454
+ .leaderboard-rank {
455
+ width: 22px;
456
+ height: 22px;
457
+ display: flex;
458
+ align-items: center;
459
+ justify-content: center;
460
+ border-radius: 50%;
461
+ font-weight: 700;
462
+ font-size: 0.7rem;
463
+ margin-right: 8px;
464
+ flex-shrink: 0;
465
+ }
466
+
467
+ .leaderboard-rank.gold {
468
+ background: linear-gradient(135deg, #ffd700, #ffb800);
469
+ color: #1a1a1a;
470
+ }
471
+
472
+ .leaderboard-rank.silver {
473
+ background: linear-gradient(135deg, #c0c0c0, #a8a8a8);
474
+ color: #1a1a1a;
475
+ }
476
+
477
+ .leaderboard-rank.bronze {
478
+ background: linear-gradient(135deg, #cd7f32, #b87333);
479
+ color: #1a1a1a;
480
+ }
481
+
482
+ .leaderboard-rank.normal {
483
+ background: #333;
484
+ color: #888;
485
+ }
486
+
487
+ .leaderboard-username {
488
+ flex: 1;
489
+ overflow: hidden;
490
+ text-overflow: ellipsis;
491
+ white-space: nowrap;
492
+ color: #ccc;
493
+ }
494
+
495
+ .leaderboard-count {
496
+ color: #ff8c5a;
497
+ font-weight: 600;
498
+ margin-left: 8px;
499
+ }
500
+
501
+ /* User search */
502
+ #user-search {
503
+ width: 100%;
504
+ padding: 6px 8px;
505
+ background: #151515;
506
+ border: 1px solid #333;
507
+ border-radius: 4px;
508
+ color: #eee;
509
+ font-size: 0.8rem;
510
+ }
511
+
512
+ #user-search:focus {
513
+ outline: none;
514
+ border-color: #ff6b35;
515
+ }
516
+
517
+ .user-dropdown {
518
+ position: absolute;
519
+ top: 100%;
520
+ left: 0;
521
+ right: 0;
522
+ max-height: 200px;
523
+ overflow-y: auto;
524
+ background: #151515;
525
+ border: 1px solid #333;
526
+ border-top: none;
527
+ border-radius: 0 0 4px 4px;
528
+ z-index: 100;
529
+ }
530
+
531
+ .user-option {
532
+ padding: 6px 10px;
533
+ cursor: pointer;
534
+ font-size: 0.75rem;
535
+ display: flex;
536
+ justify-content: space-between;
537
+ border-bottom: 1px solid #222;
538
+ }
539
+
540
+ .user-option:hover {
541
+ background: rgba(255, 107, 53, 0.1);
542
+ }
543
+
544
+ .user-option .count {
545
+ color: #555;
546
+ }
547
+
548
+ .selected-user {
549
+ display: flex;
550
+ align-items: center;
551
+ justify-content: space-between;
552
+ background: rgba(255, 107, 53, 0.15);
553
+ border: 1px solid rgba(255, 107, 53, 0.3);
554
+ border-radius: 4px;
555
+ padding: 6px 10px;
556
+ margin-top: 6px;
557
+ font-size: 0.8rem;
558
+ }
559
+
560
+ .selected-user span {
561
+ color: #ff8c5a;
562
+ font-weight: 500;
563
+ }
564
+
565
+ .btn-clear {
566
+ background: none;
567
+ border: none;
568
+ color: #888;
569
+ cursor: pointer;
570
+ font-size: 1rem;
571
+ padding: 0 4px;
572
+ }
573
+
574
+ .btn-clear:hover {
575
+ color: #ff6b35;
576
+ }
577
+
578
+ /* Scrollbar */
579
+ ::-webkit-scrollbar {
580
+ width: 4px;
581
+ }
582
+
583
+ ::-webkit-scrollbar-track {
584
+ background: #1a1a1a;
585
+ }
586
+
587
+ ::-webkit-scrollbar-thumb {
588
+ background: #333;
589
+ border-radius: 2px;
590
+ }
591
+
592
+ ::-webkit-scrollbar-thumb:hover {
593
+ background: #ff6b35;
594
+ }
595
+ </style>
596
+ </head>
597
+ <body>
598
+ <div class="container">
599
+ <!-- Sidebar -->
600
+ <div class="sidebar" id="sidebar">
601
+ <h1>Global Traffic Tracker</h1>
602
+ <p class="subtitle">Real-time Worldwide Monitor</p>
603
+
604
+ <!-- Stats -->
605
+ <div class="panel">
606
+ <h3>Statistics</h3>
607
+ <div class="stat-row">
608
+ <span class="stat-label">Total Events</span>
609
+ <span class="stat-value" id="stat-total">-</span>
610
+ </div>
611
+ <div class="stat-row">
612
+ <span class="stat-label">Unique Users</span>
613
+ <span class="stat-value" id="stat-users">-</span>
614
+ </div>
615
+ <div class="stat-row">
616
+ <span class="stat-label">Date Range</span>
617
+ <span class="stat-value" id="stat-time">-</span>
618
+ </div>
619
+ </div>
620
+
621
+ <!-- Leaderboard -->
622
+ <div class="panel leaderboard-panel">
623
+ <h3>Top Contributors</h3>
624
+ <div class="leaderboard" id="leaderboard">
625
+ <!-- Populated by JS -->
626
+ </div>
627
+ </div>
628
+
629
+ <!-- Filters -->
630
+ <div class="panel">
631
+ <h3>Filters</h3>
632
+
633
+ <div class="filter-group">
634
+ <label>Time Range</label>
635
+ <select id="filter-time" onchange="onFilterChange()">
636
+ <option value="">All Data</option>
637
+ <option value="1">Last Hour</option>
638
+ <option value="6">Last 6 Hours</option>
639
+ <option value="24">Last 24 Hours</option>
640
+ <option value="168">Last Week</option>
641
+ <option value="custom">Custom Range</option>
642
+ </select>
643
+ </div>
644
+
645
+ <div class="filter-group" id="date-range-group" style="display: none;">
646
+ <label>From</label>
647
+ <input type="date" id="filter-date-from" onchange="onFilterChange()">
648
+ <label style="margin-top: 6px;">To</label>
649
+ <input type="date" id="filter-date-to" onchange="onFilterChange()">
650
+ </div>
651
+
652
+ <div class="filter-group">
653
+ <label>Event Type (click to filter)</label>
654
+ <div class="type-filters" id="type-filters">
655
+ <!-- Populated by JS -->
656
+ </div>
657
+ </div>
658
+
659
+ <div class="filter-group">
660
+ <label>Track User</label>
661
+ <div style="position: relative;">
662
+ <input type="text" id="user-search" placeholder="Search username..."
663
+ autocomplete="off" oninput="searchUsers(this.value)" onfocus="showUserDropdown()">
664
+ <div id="user-dropdown" class="user-dropdown" style="display: none;">
665
+ <!-- Populated by JS -->
666
+ </div>
667
+ </div>
668
+ <div id="selected-user" class="selected-user" style="display: none;">
669
+ <span id="selected-user-name"></span>
670
+ <button class="btn-clear" onclick="clearUserFilter()">&times;</button>
671
+ </div>
672
+ </div>
673
+
674
+ <div style="display: flex; gap: 6px; margin-top: 8px;">
675
+ <button class="btn btn-ghost btn-small" onclick="resetFilters()">Reset All</button>
676
+ </div>
677
+ </div>
678
+
679
+ <!-- Display Options -->
680
+ <div class="panel">
681
+ <h3>Display</h3>
682
+ <div class="filter-group">
683
+ <label class="checkbox-label">
684
+ <input type="checkbox" id="show-heatmap" checked onchange="toggleHeatmap()">
685
+ Heatmap Layer
686
+ </label>
687
+ </div>
688
+ <div class="filter-group">
689
+ <label class="checkbox-label">
690
+ <input type="checkbox" id="show-markers" onchange="toggleMarkers()">
691
+ Event Markers
692
+ </label>
693
+ </div>
694
+ </div>
695
+
696
+ <!-- Live Feed -->
697
+ <div class="panel live-feed">
698
+ <div class="feed-header">
699
+ <h3>Live Feed</h3>
700
+ <div class="live-indicator">
701
+ <span class="live-dot"></span>
702
+ <span id="connection-status">Connecting...</span>
703
+ </div>
704
+ </div>
705
+ <div class="feed-items" id="feed-items">
706
+ <!-- Populated by SSE -->
707
+ </div>
708
+ </div>
709
+ </div>
710
+
711
+ <!-- Sidebar Toggle -->
712
+ <button class="sidebar-toggle" id="sidebar-toggle" onclick="toggleSidebar()">☰</button>
713
+
714
+ <!-- Map -->
715
+ <div class="map-container">
716
+ <div id="map"></div>
717
+
718
+ <!-- Status Bar -->
719
+ <div class="status-bar">
720
+ <span class="status-dot"></span>
721
+ <span id="status-text">Initializing...</span>
722
+ </div>
723
+
724
+ <!-- Legend -->
725
+ <div class="legend">
726
+ <h4>Density</h4>
727
+ <div class="legend-gradient"></div>
728
+ <div class="legend-labels">
729
+ <span>Low</span>
730
+ <span>High</span>
731
+ </div>
732
+ </div>
733
+ </div>
734
+ </div>
735
+
736
+ <!-- Leaflet JS -->
737
+ <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
738
+ <script src="https://unpkg.com/leaflet.heat@0.2.0/dist/leaflet-heat.js"></script>
739
+
740
+ <script>
741
+ // Map initialization
742
+ const map = L.map('map', { zoomControl: false }).setView([45, 10], 4);
743
+ L.control.zoom({ position: 'bottomleft' }).addTo(map);
744
+
745
+ L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
746
+ attribution: '',
747
+ maxZoom: 19,
748
+ opacity: 0.8
749
+ }).addTo(map);
750
+
751
+ // Layers
752
+ let heatLayer = null;
753
+ let markersLayer = L.layerGroup().addTo(map);
754
+
755
+ // Current filter state
756
+ let currentFilters = {
757
+ type: null,
758
+ since: null,
759
+ dateFrom: null,
760
+ dateTo: null,
761
+ user: null
762
+ };
763
+
764
+ // User search debounce timer
765
+ let userSearchTimer = null;
766
+
767
+ // Event type colors
768
+ const typeColors = {
769
+ 'POLICE': '#3b82f6', // Blue
770
+ 'JAM': '#f97316', // Orange
771
+ 'HAZARD': '#facc15', // Yellow
772
+ 'ROAD_CLOSED': '#a855f7', // Purple
773
+ 'ACCIDENT': '#ef4444', // Red
774
+ 'CONSTRUCTION': '#6b7280' // Gray
775
+ };
776
+
777
+ // Toggle sidebar
778
+ function toggleSidebar() {
779
+ const sidebar = document.getElementById('sidebar');
780
+ const toggle = document.getElementById('sidebar-toggle');
781
+ sidebar.classList.toggle('collapsed');
782
+ toggle.textContent = sidebar.classList.contains('collapsed') ? '☰' : '×';
783
+ }
784
+
785
+ // Load statistics
786
+ async function loadStats() {
787
+ try {
788
+ const res = await fetch('/api/stats');
789
+ const stats = await res.json();
790
+ document.getElementById('stat-total').textContent = stats.total_events.toLocaleString();
791
+ document.getElementById('stat-users').textContent = stats.unique_users.toLocaleString();
792
+ if (stats.first_event && stats.last_event) {
793
+ const first = stats.first_event.substring(0, 10);
794
+ const last = stats.last_event.substring(0, 10);
795
+ document.getElementById('stat-time').textContent = first === last ? first : `${first} - ${last}`;
796
+ }
797
+ } catch (err) {
798
+ console.error('Failed to load stats:', err);
799
+ }
800
+ }
801
+
802
+ // Load event types for filter
803
+ async function loadTypes() {
804
+ try {
805
+ const res = await fetch('/api/types');
806
+ const types = await res.json();
807
+ const container = document.getElementById('type-filters');
808
+ container.innerHTML = '';
809
+
810
+ types.forEach(t => {
811
+ const color = typeColors[t.type] || '#888';
812
+ const chip = document.createElement('div');
813
+ chip.className = 'type-chip';
814
+ chip.dataset.type = t.type;
815
+ chip.innerHTML = `<span class="dot" style="background: ${color}"></span>${t.type} <span style="color:#555">${t.count}</span>`;
816
+ chip.onclick = () => selectType(chip);
817
+ container.appendChild(chip);
818
+ });
819
+ } catch (err) {
820
+ console.error('Failed to load types:', err);
821
+ }
822
+ }
823
+
824
+ // Select a single type (exclusive selection)
825
+ function selectType(chip) {
826
+ const wasActive = chip.classList.contains('active');
827
+
828
+ // Deselect all
829
+ document.querySelectorAll('.type-chip').forEach(c => c.classList.remove('active'));
830
+
831
+ // If wasn't active, select this one
832
+ if (!wasActive) {
833
+ chip.classList.add('active');
834
+ currentFilters.type = chip.dataset.type;
835
+ } else {
836
+ currentFilters.type = null;
837
+ }
838
+
839
+ // Apply filters immediately
840
+ applyFilters();
841
+ }
842
+
843
+ // Handle filter changes
844
+ function onFilterChange() {
845
+ const timeValue = document.getElementById('filter-time').value;
846
+ const dateRangeGroup = document.getElementById('date-range-group');
847
+
848
+ if (timeValue === 'custom') {
849
+ dateRangeGroup.style.display = 'block';
850
+ currentFilters.since = null;
851
+ currentFilters.dateFrom = document.getElementById('filter-date-from').value || null;
852
+ currentFilters.dateTo = document.getElementById('filter-date-to').value || null;
853
+ } else {
854
+ dateRangeGroup.style.display = 'none';
855
+ currentFilters.since = timeValue || null;
856
+ currentFilters.dateFrom = null;
857
+ currentFilters.dateTo = null;
858
+ }
859
+
860
+ applyFilters();
861
+ }
862
+
863
+ // Apply all filters
864
+ function applyFilters() {
865
+ loadHeatmap();
866
+ if (document.getElementById('show-markers').checked) {
867
+ loadMarkers();
868
+ }
869
+ }
870
+
871
+ // Reset filters
872
+ function resetFilters() {
873
+ document.getElementById('filter-time').value = '';
874
+ document.getElementById('filter-date-from').value = '';
875
+ document.getElementById('filter-date-to').value = '';
876
+ document.getElementById('date-range-group').style.display = 'none';
877
+ document.getElementById('user-search').value = '';
878
+ document.getElementById('selected-user').style.display = 'none';
879
+ document.querySelectorAll('.type-chip.active').forEach(c => c.classList.remove('active'));
880
+
881
+ currentFilters = { type: null, since: null, dateFrom: null, dateTo: null, user: null };
882
+ applyFilters();
883
+ loadStats();
884
+ }
885
+
886
+ // Build URL with current filters
887
+ function buildFilterUrl(baseUrl) {
888
+ let url = baseUrl + '?';
889
+ if (currentFilters.type) url += `type=${currentFilters.type}&`;
890
+ if (currentFilters.since) url += `since=${currentFilters.since}&`;
891
+ if (currentFilters.dateFrom) url += `from=${currentFilters.dateFrom}&`;
892
+ if (currentFilters.dateTo) url += `to=${currentFilters.dateTo}&`;
893
+ if (currentFilters.user) url += `user=${encodeURIComponent(currentFilters.user)}&`;
894
+ return url;
895
+ }
896
+
897
+ // User search functionality
898
+ async function searchUsers(query) {
899
+ clearTimeout(userSearchTimer);
900
+ userSearchTimer = setTimeout(async () => {
901
+ const dropdown = document.getElementById('user-dropdown');
902
+ if (!query || query.length < 2) {
903
+ dropdown.style.display = 'none';
904
+ return;
905
+ }
906
+
907
+ try {
908
+ const res = await fetch(`/api/users?q=${encodeURIComponent(query)}&limit=20`);
909
+ const users = await res.json();
910
+
911
+ if (users.length > 0) {
912
+ dropdown.innerHTML = users.map(u =>
913
+ `<div class="user-option" onclick="selectUser('${u.username.replace(/'/g, "\\'")}')">
914
+ <span>${u.username}</span>
915
+ <span class="count">${u.count} events</span>
916
+ </div>`
917
+ ).join('');
918
+ dropdown.style.display = 'block';
919
+ } else {
920
+ dropdown.innerHTML = '<div class="user-option" style="color:#555">No users found</div>';
921
+ dropdown.style.display = 'block';
922
+ }
923
+ } catch (err) {
924
+ console.error('User search failed:', err);
925
+ }
926
+ }, 300);
927
+ }
928
+
929
+ function showUserDropdown() {
930
+ const input = document.getElementById('user-search');
931
+ if (input.value.length >= 2) {
932
+ document.getElementById('user-dropdown').style.display = 'block';
933
+ }
934
+ }
935
+
936
+ function selectUser(username) {
937
+ currentFilters.user = username;
938
+
939
+ // Update UI
940
+ document.getElementById('user-search').value = '';
941
+ document.getElementById('user-dropdown').style.display = 'none';
942
+ document.getElementById('selected-user').style.display = 'flex';
943
+ document.getElementById('selected-user-name').textContent = username;
944
+
945
+ applyFilters();
946
+
947
+ // Update status
948
+ document.getElementById('status-text').textContent = `Tracking user: ${username}`;
949
+ }
950
+
951
+ function clearUserFilter() {
952
+ currentFilters.user = null;
953
+ document.getElementById('selected-user').style.display = 'none';
954
+ document.getElementById('user-search').value = '';
955
+ applyFilters();
956
+ }
957
+
958
+ // Hide dropdown when clicking outside
959
+ document.addEventListener('click', (e) => {
960
+ if (!e.target.closest('#user-search') && !e.target.closest('#user-dropdown')) {
961
+ document.getElementById('user-dropdown').style.display = 'none';
962
+ }
963
+ });
964
+
965
+ // Load heatmap data
966
+ async function loadHeatmap() {
967
+ try {
968
+ const url = buildFilterUrl('/api/heatmap');
969
+ const res = await fetch(url);
970
+ const data = await res.json();
971
+
972
+ if (heatLayer) {
973
+ map.removeLayer(heatLayer);
974
+ heatLayer = null;
975
+ }
976
+
977
+ if (data.length > 0) {
978
+ const maxIntensity = Math.max(...data.map(d => d[2]));
979
+ heatLayer = L.heatLayer(data, {
980
+ radius: 25,
981
+ blur: 15,
982
+ maxZoom: 17,
983
+ max: maxIntensity || 1,
984
+ minOpacity: 0.3,
985
+ gradient: {
986
+ 0.0: '#2d1000',
987
+ 0.2: '#4a2000',
988
+ 0.4: '#8b3a00',
989
+ 0.6: '#cc5500',
990
+ 0.8: '#ff6b35',
991
+ 1.0: '#ff8c5a'
992
+ }
993
+ });
994
+
995
+ if (document.getElementById('show-heatmap').checked) {
996
+ heatLayer.addTo(map);
997
+ }
998
+ }
999
+
1000
+ // Update status
1001
+ document.getElementById('status-text').textContent =
1002
+ `Showing ${data.length} locations` + (currentFilters.type ? ` (${currentFilters.type})` : '');
1003
+ } catch (err) {
1004
+ console.error('Failed to load heatmap:', err);
1005
+ }
1006
+ }
1007
+
1008
+ // Load markers
1009
+ async function loadMarkers() {
1010
+ try {
1011
+ const url = buildFilterUrl('/api/events') + 'limit=500&';
1012
+ const res = await fetch(url);
1013
+ const events = await res.json();
1014
+
1015
+ markersLayer.clearLayers();
1016
+
1017
+ events.forEach(event => {
1018
+ const color = typeColors[event.type] || '#888';
1019
+ const marker = L.circleMarker([event.latitude, event.longitude], {
1020
+ radius: 5,
1021
+ fillColor: color,
1022
+ color: '#fff',
1023
+ weight: 1,
1024
+ opacity: 1,
1025
+ fillOpacity: 0.8
1026
+ });
1027
+
1028
+ marker.bindPopup(`
1029
+ <div class="event-popup">
1030
+ <h4>${event.type}</h4>
1031
+ <div class="detail"><strong>User:</strong> ${event.username}</div>
1032
+ <div class="detail"><strong>Time:</strong> ${event.timestamp.substring(0, 19)}</div>
1033
+ <div class="detail"><strong>Location:</strong> ${event.latitude.toFixed(4)}, ${event.longitude.toFixed(4)}</div>
1034
+ ${event.subtype ? `<div class="detail"><strong>Subtype:</strong> ${event.subtype}</div>` : ''}
1035
+ </div>
1036
+ `);
1037
+
1038
+ markersLayer.addLayer(marker);
1039
+ });
1040
+ } catch (err) {
1041
+ console.error('Failed to load markers:', err);
1042
+ }
1043
+ }
1044
+
1045
+ // Toggle heatmap visibility
1046
+ function toggleHeatmap() {
1047
+ if (document.getElementById('show-heatmap').checked) {
1048
+ if (heatLayer) heatLayer.addTo(map);
1049
+ else loadHeatmap();
1050
+ } else if (heatLayer) {
1051
+ map.removeLayer(heatLayer);
1052
+ }
1053
+ }
1054
+
1055
+ // Toggle markers visibility
1056
+ function toggleMarkers() {
1057
+ if (document.getElementById('show-markers').checked) {
1058
+ loadMarkers();
1059
+ } else {
1060
+ markersLayer.clearLayers();
1061
+ }
1062
+ }
1063
+
1064
+ // Add item to live feed
1065
+ function addFeedItem(data) {
1066
+ const feed = document.getElementById('feed-items');
1067
+ const item = document.createElement('div');
1068
+ item.className = 'feed-item new';
1069
+
1070
+ if (data.type === 'new_event' && data.event) {
1071
+ const e = data.event;
1072
+ const time = new Date(e.timestamp).toLocaleTimeString();
1073
+ const color = typeColors[e.report_type] || '#888';
1074
+ item.innerHTML = `
1075
+ <span class="type" style="color:${color}">${e.report_type}</span>
1076
+ <span class="location">${e.grid_cell || `${e.latitude.toFixed(2)}, ${e.longitude.toFixed(2)}`}</span>
1077
+ <span class="time">${time}</span>
1078
+ `;
1079
+ item.style.borderLeftColor = color;
1080
+
1081
+ // Make event items clickable to navigate to location
1082
+ if (e.latitude && e.longitude) {
1083
+ item.classList.add('clickable');
1084
+ item.dataset.lat = e.latitude;
1085
+ item.dataset.lng = e.longitude;
1086
+ item.dataset.type = e.report_type;
1087
+ item.title = 'Click to view on map';
1088
+ item.addEventListener('click', () => {
1089
+ const lat = parseFloat(item.dataset.lat);
1090
+ const lng = parseFloat(item.dataset.lng);
1091
+ map.flyTo([lat, lng], 14, { duration: 1 });
1092
+
1093
+ // Add a temporary marker to highlight the location
1094
+ const markerColor = typeColors[item.dataset.type] || '#ff6b35';
1095
+ const pulseMarker = L.circleMarker([lat, lng], {
1096
+ radius: 12,
1097
+ color: markerColor,
1098
+ fillColor: markerColor,
1099
+ fillOpacity: 0.5,
1100
+ weight: 3
1101
+ }).addTo(map);
1102
+
1103
+ // Pulse animation then remove
1104
+ setTimeout(() => map.removeLayer(pulseMarker), 3000);
1105
+ });
1106
+ }
1107
+ } else if (data.type === 'status') {
1108
+ // Skip status items with 0 alerts and 0 new events
1109
+ if (data.alerts_found === 0 && data.new_events === 0) {
1110
+ return;
1111
+ }
1112
+
1113
+ const color = typeColors[data.event_types?.[0]] || '#ff6b35';
1114
+ item.innerHTML = `
1115
+ <span class="type" style="color:#888">SCAN</span>
1116
+ <span class="location">${data.cell_name} (${data.country})</span>
1117
+ <span class="time">${data.cell_idx}/${data.total_cells} • +${data.new_events}</span>
1118
+ `;
1119
+ item.style.borderLeftColor = data.new_events > 0 ? '#ff6b35' : '#333';
1120
+
1121
+ // Update status bar
1122
+ document.getElementById('status-text').textContent =
1123
+ `[${data.region.toUpperCase()}] ${data.cell_name} (${data.country}) • ${data.alerts_found} alerts, +${data.new_events} new`;
1124
+ } else {
1125
+ return; // Skip heartbeats and unknown types
1126
+ }
1127
+
1128
+ // Insert at top
1129
+ feed.insertBefore(item, feed.firstChild);
1130
+
1131
+ // Remove 'new' class after animation
1132
+ setTimeout(() => item.classList.remove('new'), 1000);
1133
+
1134
+ // Keep only last 50 items
1135
+ while (feed.children.length > 50) {
1136
+ feed.removeChild(feed.lastChild);
1137
+ }
1138
+ }
1139
+
1140
+ // Connect to SSE stream
1141
+ function connectSSE() {
1142
+ const statusEl = document.getElementById('connection-status');
1143
+ statusEl.textContent = 'Connecting...';
1144
+
1145
+ const eventSource = new EventSource('/api/stream');
1146
+
1147
+ eventSource.onopen = () => {
1148
+ statusEl.textContent = 'Connected';
1149
+ };
1150
+
1151
+ eventSource.onmessage = (event) => {
1152
+ try {
1153
+ const data = JSON.parse(event.data);
1154
+
1155
+ if (data.type === 'connected') {
1156
+ statusEl.textContent = 'Live';
1157
+ } else if (data.type === 'heartbeat') {
1158
+ // Ignore heartbeats
1159
+ } else if (data.type === 'new_event') {
1160
+ addFeedItem(data);
1161
+ // Refresh stats periodically
1162
+ loadStats();
1163
+ } else if (data.type === 'status') {
1164
+ addFeedItem(data);
1165
+ }
1166
+ } catch (err) {
1167
+ console.error('SSE parse error:', err);
1168
+ }
1169
+ };
1170
+
1171
+ eventSource.onerror = () => {
1172
+ statusEl.textContent = 'Reconnecting...';
1173
+ eventSource.close();
1174
+ // Reconnect after 5 seconds
1175
+ setTimeout(connectSSE, 5000);
1176
+ };
1177
+ }
1178
+
1179
+ // Load recent activity on startup
1180
+ async function loadRecentActivity() {
1181
+ try {
1182
+ const res = await fetch('/api/recent-activity');
1183
+ const events = await res.json();
1184
+
1185
+ // Add last 10 events to feed (reversed so newest is on top)
1186
+ events.slice(0, 10).forEach(e => {
1187
+ addFeedItem({
1188
+ type: 'new_event',
1189
+ event: {
1190
+ ...e,
1191
+ report_type: e.type
1192
+ }
1193
+ });
1194
+ });
1195
+ } catch (err) {
1196
+ console.error('Failed to load recent activity:', err);
1197
+ }
1198
+ }
1199
+
1200
+ // Load leaderboard
1201
+ async function loadLeaderboard() {
1202
+ try {
1203
+ const res = await fetch('/api/leaderboard?limit=10');
1204
+ const users = await res.json();
1205
+ const container = document.getElementById('leaderboard');
1206
+
1207
+ container.innerHTML = users.map(user => {
1208
+ let rankClass = 'normal';
1209
+ if (user.rank === 1) rankClass = 'gold';
1210
+ else if (user.rank === 2) rankClass = 'silver';
1211
+ else if (user.rank === 3) rankClass = 'bronze';
1212
+
1213
+ return `
1214
+ <div class="leaderboard-item" onclick="selectUser('${user.username.replace(/'/g, "\\'")}')">
1215
+ <span class="leaderboard-rank ${rankClass}">${user.rank}</span>
1216
+ <span class="leaderboard-username">${user.username}</span>
1217
+ <span class="leaderboard-count">${user.count.toLocaleString()}</span>
1218
+ </div>
1219
+ `;
1220
+ }).join('');
1221
+ } catch (err) {
1222
+ console.error('Failed to load leaderboard:', err);
1223
+ }
1224
+ }
1225
+
1226
+ // Initialize
1227
+ loadStats();
1228
+ loadTypes();
1229
+ loadLeaderboard();
1230
+ loadHeatmap();
1231
+ loadRecentActivity();
1232
+ connectSSE();
1233
+
1234
+ // Refresh stats and leaderboard every 60 seconds
1235
+ setInterval(() => {
1236
+ loadStats();
1237
+ loadLeaderboard();
1238
+ }, 60000);
1239
+ </script>
1240
+ </body>
1241
+ </html>