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.
- analysis.py +91 -0
- cli.py +1219 -0
- collector.py +193 -0
- collector_europe.py +312 -0
- collector_worldwide.py +532 -0
- database.py +176 -0
- waze_client.py +234 -0
- waze_logs-1.0.0.dist-info/METADATA +411 -0
- waze_logs-1.0.0.dist-info/RECORD +15 -0
- waze_logs-1.0.0.dist-info/WHEEL +5 -0
- waze_logs-1.0.0.dist-info/entry_points.txt +2 -0
- waze_logs-1.0.0.dist-info/licenses/LICENSE +21 -0
- waze_logs-1.0.0.dist-info/top_level.txt +8 -0
- web/app.py +536 -0
- web/templates/index.html +1241 -0
web/templates/index.html
ADDED
|
@@ -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()">×</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>
|