Setting the file. One moment. Index · Geofeed Tuner · github/awesome-copilot · Skills Docs(opens in a new tab)
scripts/templates/index.html
HTML·2,305 lines·98 KB
link
href
=
"https://fonts.googleapis.com/css2?family=Raleway:wght@300;400;600;700&display=swap"
rel
=
"stylesheet"
>
8 <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
9 <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
10 <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
11 <script src="https://unpkg.com/h3-js@4.1.0/dist/h3-js.umd.js"></script>
12 <style>
13 body {
14 background-color: #adadad;
15 padding: 20px;
16 font-family: 'Raleway', sans-serif;
17 }
18 i[class*="bi"] {
19 font-weight: 700;
20 text-shadow: 0 0 0.3px currentColor;
21 }
22 .report-container {
23 background-color: white;
24 border-radius: 15px;
25 box-shadow: 0 2px 4px rgba(0,0,0,0.1);
26 padding: 30px;
27 margin: 0 auto 20px auto;
28 max-width: 1400px;
29 }
30 .metadata {
31 background-color: #f0f0f0;
32 padding: 20px;
33 border-radius: 5px;
34 margin-bottom: 30px;
35 }
36 .metadata h3 {
37 margin-bottom: 15px;
38 color: #333333;
39 }
40 .metadata-item {
41 display: inline-block;
42 margin-right: 40px;
43 margin-bottom: 10px;
44 }
45 .metadata-label {
46 font-weight: bold;
47 color: #696969;
48 }
49 .metadata-value {
50 color: #333333;
51 }
52 .status-badge {
53 padding: 3px 10px;
54 border-radius: 4px;
55 font-weight: bold;
56 font-size: 0.75em;
57 display: inline-flex;
58 align-items: center;
59 justify-content: center;
60 gap: 4px;
61 white-space: nowrap;
62 width: 150px;
63 }
64 .status-error {
65 background-color: #ff707f;
66 color: #ffffff;
67 border: 1px solid #ff707f;
68 }
69 .status-warning {
70 background-color: #ffdc73;
71 color: #000000;
72 border: 1px solid #ffdc73;
73 }
74 .status-suggestion {
75 background-color: #1877ec;
76 color: #ffffff;
77 border: 1px solid #1877ec;
78 }
79 .status-ok {
80 background-color: #28a745;
81 color: #ffffff;
82 border: 1px solid #28a745;
83 }
84 /* Checkbox styling */
85 input[type="checkbox"] {
86 width: 16px;
87 height: 16px;
88 cursor: pointer;
89 accent-color: #4a4a4a;
90 display: block;
91 margin: 0 auto;
92 }
93 input[type="checkbox"]:hover {
94 transform: scale(1.1);
95 }
96 /* Disabled checkbox with X effect */
97 input[type="checkbox"]:disabled {
98 cursor: not-allowed;
99 opacity: 0.6;
100 position: relative;
101 }
102 input[type="checkbox"]:disabled:hover {
103 transform: none;
104 }
105 /* Create X overlay on disabled checkbox */
106 .message-line input[type="checkbox"]:disabled {
107 appearance: none;
108 -webkit-appearance: none;
109 -moz-appearance: none;
110 width: 16px;
111 height: 16px;
112 border: 2px solid #dc3545;
113 border-radius: 3px;
114 background-color: #dc3545;
115 position: relative;
116 cursor: not-allowed;
117 }
118 .message-line input[type="checkbox"]:disabled::before,
119 .message-line input[type="checkbox"]:disabled::after {
120 content: '';
121 position: absolute;
122 top: 50%;
123 left: 50%;
124 width: 10px;
125 height: 2px;
126 background-color: #ffffff;
127 }
128 .message-line input[type="checkbox"]:disabled::before {
129 transform: translate(-50%, -50%) rotate(45deg);
130 }
131 .message-line input[type="checkbox"]:disabled::after {
132 transform: translate(-50%, -50%) rotate(-45deg);
133 }
134 #selectAll {
135 width: 17px;
136 height: 17px;
137 }
138 .entries-table td:first-child,
139 .entries-table th:first-child {
140 text-align: center;
141 vertical-align: middle;
142 }
143 .entries-table {
144 width: 100%;
145 border-collapse: collapse;
146 border-radius: 8px;
147 overflow: hidden;
148 table-layout: fixed;
149 }
150 .entries-table thead {
151 background-color: #4a4a4a;
152 color: white;
153 }
154 .entries-table th {
155 padding: 12px;
156 text-align: left;
157 border: 1px solid #d3d3d3;
158 font-weight: 600;
159 background-color: #6b6b6b;
160 color: #ffffff;
161 }
162 .entries-table td {
163 padding: 12px;
164 border: 1px solid #d3d3d3;
165 vertical-align: top;
166 word-wrap: break-word;
167 overflow-wrap: break-word;
168 }
169 .entries-table tbody tr:not(.expand-details-row) td:nth-child(3) {
170 text-align: center;
171 vertical-align: top;
172 }
173 .entries-table tbody tr:nth-child(odd) {
174 background-color: #f9f9f9;
175 }
176 .entries-table tbody tr:hover {
177 background-color: #f0f0f0;
178 }
179 .messages-list {
180 list-style: none;
181 padding: 0;
182 margin: 0;
183 }
184 .messages-list li {
185 padding: 1px 0;
186 border-left: 3px solid #b0b0b0;
187 padding-left: 10px;
188 color: #696969;
189 font-size: 0.95em;
190 }
191 h1 {
192 color: #ffffff;
193 margin-bottom: 10px;
194 margin-top: -30px;
195 margin-left: -30px;
196 margin-right: -30px;
197 border-bottom: none;
198 padding-bottom: 10px;
199 font-size: 3em;
200 font-weight: 700;
201 font-family: 'Raleway', sans-serif;
202 background: linear-gradient(135deg, #2a2a2a 0%, #4a4a4a 100%);
203 padding: 30px;
204 border-radius: 15px 15px 0 0;
205 text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
206 }
207 .metrics-table {
208 width: 100%;
209 border-collapse: collapse;
210 margin: 0 auto 20px auto;
211 border-radius: 8px;
212 overflow: hidden;
213 }
214 .metrics-table td {
215 padding: 10px 15px;
216 border: 1px solid #d3d3d3;
217 }
218 .metrics-table tr:nth-child(even) {
219 background-color: #f9f9f9;
220 }
221 .metrics-label {
222 font-weight: bold;
223 width: 30%;
224 background-color: #f0f0f0;
225 }
226 .metrics-value {
227 width: 70%;
228 color: #333333;
229 }
230 .metrics-table.feed-metadata .metrics-label {
231 width: 20%;
232 }
233 .metrics-table.feed-metadata .metrics-value {
234 width: 80%;
235 }
236 .accuracy-summary-table td {
237 width: auto !important;
238 }
239 .accuracy-summary-table .metrics-label {
240 width: 15% !important;
241 }
242 .accuracy-summary-table .metrics-value {
243 width: 35% !important;
244 }
245 #filterCountry, #filterRegion, #filterCity, #filterStatus, #filterIpPrefix {
246 box-sizing: border-box;
247 font-family: 'Raleway', sans-serif;
248 }
249 #filterCountry::placeholder, #filterRegion::placeholder, #filterCity::placeholder, #filterIpPrefix::placeholder {
250 color: #aaa;
251 }
252 #resetFilters:hover, #toggleFilters:hover, #toggleExpandAll:hover, #downloadCSV:hover {
253 background-color: #6a6a6a;
254 border-color: #aaa;
255 }
256 .expand-details-row {
257 display: none;
258 }
259 .expand-details-row.show {
260 display: table-row;
261 }
262 .expand-details-row td {
263 border-left: none;
264 border-right: none;
265 }
266 .expand-details-row td:first-child {
267 border-left: 1px solid #d3d3d3;
268 padding: 0;
269 }
270 .expand-details-row td:nth-child(2) {
271 padding: 0;
272 }
273 .expand-details-row td:last-child {
274 border-right: 1px solid #d3d3d3;
275 }
276 .previous-values-row td {
277 background-color: #f0f0f0;
278 font-style: italic;
279 color: #555;
280 padding: 8px 12px;
281 border-top: 1px solid #d3d3d3;
282 border-bottom: 1px solid #d3d3d3;
283 }
284 .previous-values-row td:first-child {
285 border-left: 1px solid #d3d3d3;
286 }
287 .previous-values-row td:last-child {
288 border-right: 1px solid #d3d3d3;
289 }
290 .issues-row td {
291 border-top: 1px solid #d3d3d3;
292 border-bottom: 1px solid #d3d3d3;
293 }
294 .expandable-row {
295 cursor: pointer;
296 }
297 .expandable-row:focus {
298 outline: 2px solid #4a9eff;
299 outline-offset: -2px;
300 }
301 .tune-all-btn {
302 display: none;
303 padding: 3px 8px;
304 border: 1px solid #888;
305 border-radius: 3px;
306 background-color: #5a5a5a;
307 color: #ffffff;
308 cursor: pointer;
309 font-weight: 600;
310 font-size: 0.8em;
311 }
312 body.tuning-mode .tune-all-btn {
313 display: inline;
314 }
315 .issues-header {
316 display: flex;
317 justify-content: space-between;
318 align-items: center;
319 }
320 .message-line {
321 display: flex;
322 justify-content: space-between;
323 align-items: center;
324 margin: 5px 0;
325 }
326 .message-line input[type="checkbox"] {
327 display: none;
328 }
329 body.tuning-mode .message-line input[type="checkbox"] {
330 display: inline-block;
331 }
332 .message-line span {
333 flex: 1;
334 font-size: 0.98em;
335 }
336 .modal {
337 display: none;
338 position: fixed;
339 z-index: 1000;
340 left: 0;
341 top: 0;
342 width: 100%;
343 height: 100%;
344 background-color: rgba(0, 0, 0, 0.6);
345 }
346 .modal-content {
347 background-color: #ffffff;
348 margin: 50px auto;
349 padding: 0;
350 border: none;
351 width: 90%;
352 max-width: 1200px;
353 border-radius: 15px;
354 max-height: 90vh;
355 overflow: hidden;
356 box-shadow: 0 4px 20px rgba(0,0,0,0.3);
357 display: flex;
358 flex-direction: column;
359 }
360 .modal-content h2 {
361 background-color: #4a4a4a;
362 color: #ffffff;
363 padding: 20px 25px;
364 margin: 0;
365 font-size: 1.3em;
366 font-weight: bold;
367 font-family: 'Raleway', sans-serif;
368 border-radius: 15px 15px 0 0;
369 position: relative;
370 }
371 .close {
372 color: #ffffff;
373 position: absolute;
374 right: 20px;
375 top: 50%;
376 transform: translateY(-50%);
377 font-size: 32px;
378 font-weight: bold;
379 cursor: pointer;
380 line-height: 1;
381 transition: color 0.2s;
382 }
383 .close:hover,
384 .close:focus {
385 color: #ddd;
386 }
387 .matches-container {
388 margin-top: 0;
389 padding: 25px;
390 display: flex;
391 flex-wrap: nowrap;
392 gap: 15px;
393 justify-content: center;
394 overflow-x: auto;
395 background-color: #f5f5f5;
396 flex: 1;
397 overflow-y: auto;
398 }
399 .match-item {
400 flex: 1 1 0;
401 max-width: 350px;
402 min-width: 280px;
403 border: 2px solid #c0c0c0;
404 border-radius: 8px;
405 padding: 0;
406 background-color: white;
407 box-shadow: 0 2px 8px rgba(0,0,0,0.15);
408 cursor: pointer;
409 transition: all 0.2s;
410 overflow: hidden;
411 }
412 .match-item:hover {
413 box-shadow: 0 4px 12px rgba(0,0,0,0.25);
414 border-color: #4a9eff;
415 transform: translateY(-2px);
416 }
417 .match-item:active {
418 transform: translateY(0);
419 }
420 .match-item.selected {
421 border-color: #4a9eff;
422 border-width: 3px;
423 box-shadow: 0 0 15px rgba(74, 158, 255, 0.6);
424 }
425 .match-map {
426 height: 180px;
427 width: 100%;
428 margin: 0;
429 border: none;
430 border-radius: 0;
431 }
432 .match-info-container {
433 display: flex;
434 flex-direction: column;
435 gap: 6px;
436 padding: 12px;
437 background-color: #f0f0f0;
438 }
439 .match-header {
440 font-size: 22px;
441 font-weight: bold;
442 color: #333;
443 text-align: left;
444 font-family: 'Raleway', sans-serif;
445 line-height: 1.2;
446 }
447 .location-details {
448 font-size: 15px;
449 color: #666;
450 display: inline;
451 }
452 .location-details span {
453 margin-right: 15px;
454 }
455 .default-values {
456 display: none;
457 padding: 5px 0;
458 font-size: 13px;
459 margin-bottom: 10px;
460 }
461 .default-values.show {
462 display: block;
463 }
464 .mode-toggle-container {
465 display: flex;
466 align-items: center;
467 gap: 10px;
468 font-size: 0.72em;
469 }
470 .mode-toggle {
471 position: relative;
472 width: 240px;
473 height: 32px;
474 background-color: #888;
475 border-radius: 16px;
476 cursor: pointer;
477 transition: background-color 0.3s;
478 display: flex;
479 align-items: center;
480 justify-content: space-between;
481 padding: 0 8px;
482 }
483 .mode-toggle:focus {
484 outline: 2px solid #4a9eff;
485 outline-offset: 2px;
486 }
487 .mode-toggle-text {
488 font-size: 0.85em;
489 font-weight: 600;
490 color: #cccccc;
491 z-index: 1;
492 transition: color 0.3s;
493 flex: 1;
494 text-align: center;
495 }
496 .mode-toggle-text.active {
497 color: #333333;
498 }
499 .mode-toggle-slider {
500 position: absolute;
501 top: 3px;
502 left: 3px;
503 width: 116px;
504 height: 26px;
505 background-color: #ffffff;
506 border-radius: 13px;
507 transition: transform 0.3s;
508 box-shadow: 0 2px 4px rgba(0,0,0,0.2);
509 }
510 .mode-toggle.tuning .mode-toggle-slider {
511 transform: translateX(118px);
512 }
513 .mode-toggle.h3mode .mode-toggle-slider {
514 transform: translateX(96px);
515 }
516 .mode-toggle-label {
517 font-weight: 600;
518 color: #ffffff;
519 font-size: 0.9em;
520 }
521
522 /* Keyboard accessibility focus styles */
523 button:focus {
524 outline: 2px solid #4a9eff;
525 outline-offset: 2px;
526 }
527 input[type="text"]:focus,
528 select:focus {
529 outline: 2px solid #4a9eff;
530 outline-offset: 1px;
531 }
532 input[type="checkbox"]:focus {
533 outline: 2px solid #4a9eff;
534 outline-offset: 2px;
535 }
536 /* Pagination styles */
537 .pagination-container {
538 display: flex;
539 justify-content: space-between;
540 align-items: center;
541 margin-top: 20px;
542 padding: 15px 20px;
543 background-color: #f5f5f5;
544 border-radius: 8px;
545 flex-wrap: wrap;
546 gap: 15px;
547 }
548 .pagination-info {
549 color: #333333;
550 font-size: 0.95em;
551 }
552 .pagination-controls {
553 display: flex;
554 gap: 5px;
555 align-items: center;
556 }
557 .pagination-btn {
558 padding: 6px 12px;
559 border: 1px solid #888;
560 border-radius: 4px;
561 background-color: #5a5a5a;
562 color: #ffffff;
563 cursor: pointer;
564 font-weight: 600;
565 font-size: 0.9em;
566 transition: background-color 0.2s;
567 }
568 .pagination-btn:hover:not(:disabled) {
569 background-color: #4a4a4a;
570 }
571 .pagination-btn:disabled {
572 background-color: #888;
573 cursor: not-allowed;
574 opacity: 0.5;
575 }
576 .pagination-btn.active {
577 background-color: #1877ec;
578 border-color: #1877ec;
579 }
580 .page-size-selector {
581 display: flex;
582 align-items: center;
583 gap: 8px;
584 }
585 .page-size-selector select {
586 padding: 5px 10px;
587 border: 1px solid #888;
588 border-radius: 4px;
589 background-color: #5a5a5a;
590 color: #ffffff;
591 font-size: 0.9em;
592 cursor: pointer;
593 }
594 /* Helper classes for common inline styles */
595 .table-header {
596 background-color: #6b6b6b;
597 padding: 12px 15px;
598 font-weight: bold;
599 color: #ffffff;
600 font-size: 1.1em;
601 font-family: 'Raleway', sans-serif;
602 }
603 .icon-spacer {
604 margin-right: 8px;
605 }
606 .flex-container {
607 display: flex;
608 justify-content: space-between;
609 align-items: center;
610 }
611 .action-button {
612 padding: 5px 11px;
613 border: 1px solid #888;
614 border-radius: 3px;
615 background-color: #5a5a5a;
616 color: #ffffff;
617 cursor: pointer;
618 font-weight: 600;
619 font-size: 0.9em;
620 }
621 .action-button:hover {
622 background-color: #6a6a6a;
623 }
624 .subtitle-span {
625 font-size: 0.4em;
626 font-weight: 700;
627 display: block;
628 margin-top: 10px;
629 }
630 </style>
631</head>
632<body>
633 <div class="report-container">
634 <h1>Geofeed Tuner Report<br><span style="font-size: 0.4em; font-weight: 700; display: block; margin-top: 10px;">RFC 8805 Validation & Recommendations</span></h1>
635
636 <table class="metrics-table feed-metadata" style="margin-top: 30px; margin-bottom: 30px;">
637 <tr style="background-color: #6b6b6b;">
638 <td colspan="2" style="padding: 12px 15px; font-weight: bold; color: #ffffff; font-size: 1.1em; font-family: 'Raleway', sans-serif;">Feed Metadata</td>
639 </tr>
640 <tr>
641 <td class="metrics-label"><i class="bi bi-filetype-csv" style="margin-right: 8px;"></i>Input file</td>
642 <td class="metrics-value"><span id="inputFileMetrics">{{.Metadata.InputFile}}</span></td>
643 </tr>
644 <tr>
645 <td class="metrics-label"><i class="bi bi-calendar-event-fill" style="margin-right: 8px;"></i>Tuning timestamp</td>
646 <td class="metrics-value"><script>document.write(new Intl.DateTimeFormat("en", {dateStyle: "full", timeStyle: "long",}).format(new Date({{.Metadata.Timestamp}})))</script></td>
647 </tr>
648 </table>
649
650 <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 20px;">
651 <table class="metrics-table">
652 <tr style="background-color: #6b6b6b;">
653 <td colspan="2" style="padding: 12px 15px; font-weight: bold; color: #ffffff; font-size: 1.1em; font-family: 'Raleway', sans-serif;">Entries</td>
654 </tr>
655 <tr>
656 <td class="metrics-label"><i class="bi bi-list-ul" style="margin-right: 8px;"></i>Total entries</td>
657 <td class="metrics-value"><span id="totalEntriesMetrics">{{.Metadata.TotalEntries}}</span></td>
658 </tr>
659 <tr>
660 <td class="metrics-label"><i class="bi bi-diagram-3-fill" style="margin-right: 8px;"></i>IPv4 entries</td>
661 <td class="metrics-value"><span id="ipv4EntriesMetrics">{{.Metadata.IpV4Entries}}</span></td>
662 </tr>
663 <tr>
664 <td class="metrics-label"><i class="bi bi-diagram-3-fill" style="margin-right: 8px;"></i>IPv6 entries</td>
665 <td class="metrics-value"><span id="ipv6EntriesMetrics">{{.Metadata.IpV6Entries}}</span></td>
666 </tr>
667 <tr>
668 <td class="metrics-label"><i class="bi bi-exclamation-circle-fill" style="margin-right: 8px;"></i>Invalid entries</td>
669 <td class="metrics-value"><span id="invalidEntriesMetrics">{{.Metadata.InvalidEntries}}</span></td>
670 </tr>
671 </table>
672
673 <table class="metrics-table">
674 <tr style="background-color: #6b6b6b;">
675 <td colspan="2" style="padding: 12px 15px; font-weight: bold; color: #ffffff; font-size: 1.1em; font-family: 'Raleway', sans-serif;">Analysis Summary</td>
676 </tr>
677 <tr>
678 <td class="metrics-label"><i class="bi bi-x-circle-fill" style="margin-right: 8px;"></i>Errors</td>
679 <td class="metrics-value"><span id="errorCountMetrics">{{.Metadata.Errors}}</span></td>
680 </tr>
681 <tr>
682 <td class="metrics-label"><i class="bi bi-exclamation-triangle-fill" style="margin-right: 8px;"></i>Warnings</td>
683 <td class="metrics-value"><span id="warningCountMetrics">{{.Metadata.Warnings}}</span></td>
684 </tr>
685 <tr>
686 <td class="metrics-label"><i class="bi bi-lightbulb-fill" style="margin-right: 8px;"></i>Suggestions</td>
687 <td class="metrics-value"><span id="suggestionsMetrics">{{.Metadata.Suggestions}}</span></td>
688 </tr>
689 <tr>
690 <td class="metrics-label"><i class="bi bi-check-circle-fill" style="margin-right: 8px;"></i>OK </td>
691 <td class="metrics-value"><span id="okCountMetrics">{{.Metadata.OK}}</span></td>
692 </tr>
693 </table>
694 </div>
695
696 <div style="margin-bottom: 40px;">
697 <table class="metrics-table accuracy-summary-table" style="margin-top: 0; margin-bottom: 0; table-layout: fixed; width: 100%;">
698 <colgroup>
699 <col style="width: 20%;">
700 <col style="width: 30%;">
701 <col style="width: 20%;">
702 <col style="width: 30%;">
703 </colgroup>
704 <tr style="background-color: #6b6b6b;">
705 <td colspan="4" style="padding: 12px 15px; font-weight: bold; color: #ffffff; font-size: 1.1em; font-family: 'Raleway', sans-serif;">Accuracy Summary</td>
706 </tr>
707 <tr>
708 <td class="metrics-label"><i class="bi bi-building-fill" style="margin-right: 8px;"></i>City-level accuracy</td>
709 <td class="metrics-value"><span id="cityAccuracy">{{.Metadata.CityLevelAccuracy}}</span></td>
710 <td class="metrics-label"><i class="bi bi-map-fill" style="margin-right: 8px;"></i>Region-level accuracy</td>
711 <td class="metrics-value"><span id="regionAccuracy">{{.Metadata.RegionLevelAccuracy}}</span></td>
712 </tr>
713 <tr>
714 <td class="metrics-label"><i class="bi bi-globe-americas" style="margin-right: 8px;"></i>Country-level accuracy</td>
715 <td class="metrics-value"><span id="countryAccuracy">{{.Metadata.CountryLevelAccuracy}}</span></td>
716 <td class="metrics-label"><i class="bi bi-ban-fill" style="margin-right: 8px;"></i>Do-not-geolocate entries</td>
717 <td class="metrics-value"><span id="doNotGeolocate">{{.Metadata.DoNotGeolocate}}</span></td>
718 </tr>
719 </table>
720 </div>
721
722 <div style="overflow-x: auto;">
723 <table class="entries-table">
724 <colgroup>
725 <col style="width: 3%;">
726 <col style="width: 5%;">
727 <col style="width: 20%;">
728 <col style="width: 24%;">
729 <col style="width: 10%;">
730 <col style="width: 19%;">
731 <col style="width: 19%;">
732 </colgroup>
733 <thead>
734 <tr style="background-color: #4a4a4a;">
735 <th colspan="7" style="padding: 12px; font-weight: bold; color: #ffffff; font-size: 1.3em; font-family: 'Raleway', sans-serif;">
736 <div style="display: flex; justify-content: space-between; align-items: center;">
737 <span><i class="bi bi-list-ul" style="margin-right: 10px;"></i>Entries</span>
738 <div class="mode-toggle-container">
739 <div class="mode-toggle" id="modeToggle" onclick="toggleMode()" tabindex="0" onkeydown="if(event.key === 'Enter' || event.key === ' ') { event.preventDefault(); toggleMode(); }">
740 <span class="mode-toggle-text active" id="viewingText">Viewing Mode</span>
741 <div class="mode-toggle-slider"></div>
742 <span class="mode-toggle-text" id="tuningText">Tuning Mode</span>
743 </div>
744 </div>
745 </div>
746 </th>
747 </tr>
748 <tr>
749 <th colspan="7" style="background-color: #6b6b6b; padding: 8px;">
750 <div style="display: flex; justify-content: space-between; gap: 5px;">
751 <div style="display: flex; gap: 5px;">
752 <button id="toggleFilters" style="padding: 5px 11px; border: 1px solid #888; border-radius: 3px; background-color: #5a5a5a; color: #ffffff; cursor: pointer; font-weight: 600; font-size: 0.9em; width: 130px;">
753 <i class="bi bi-funnel-fill" style="margin-right: 5px;"></i>Show Filters
754 </button>
755 <button id="toggleExpandAll" style="padding: 5px 11px; border: 1px solid #888; border-radius: 3px; background-color: #5a5a5a; color: #ffffff; cursor: pointer; font-weight: 600; font-size: 0.9em; width: 145px;">
756 <i class="bi bi-arrows-expand" style="margin-right: 5px;"></i>Expand Rows
757 </button>
758 <button id="tuneAll" class="tune-all-btn" style="padding: 5px 11px; border: 1px solid #888; border-radius: 3px; background-color: #5a5a5a; color: #ffffff; cursor: pointer; font-weight: 600; font-size: 0.9em;">
759 <i class="bi bi-wrench" style="margin-right: 5px;"></i>Tune All
760 </button>
761 </div>
762 <button id="downloadCSV" style="padding: 5px 11px; border: 1px solid #888; border-radius: 3px; background-color: #5a5a5a; color: #ffffff; cursor: pointer; font-weight: 600; font-size: 0.9em;">
763 <i class="bi bi-filetype-csv" style="margin-right: 5px;"></i>Download
764 </button>
765 </div>
766 </th>
767 </tr>
768 <tr>
769 <th><input type="checkbox" id="selectAll" checked></th>
770 <th>Line</th>
771 <th>Status</th>
772 <th>IP Prefix</th>
773 <th>Country</th>
774 <th>Region</th>
775 <th>City</th>
776 </tr>
777 <tr id="filterRow" style="background-color: #6b6b6b; display: none;">
778 <th colspan="2" style="padding: 8px;">
779 <button id="resetFilters" style="padding: 5px 11px; border: 1px solid #888; border-radius: 3px; background-color: #5a5a5a; color: #ffffff; cursor: pointer; font-weight: 600; font-size: 0.9em; width: 100%;">Reset</button>
780 </th>
781 <th style="padding: 8px;">
782 <select id="filterStatus" style="padding: 5px 9px; border: 1px solid #888; border-radius: 3px; background-color: #5a5a5a; color: #ffffff; font-size: 0.9em; width: 100%;">
783 <option value="">All</option>
784 <option value="ERROR">ERROR</option>
785 <option value="WARNING">WARNING</option>
786 <option value="SUGGESTION">SUGGESTION</option>
787 <option value="OK">OK</option>
788 </select>
789 </th>
790 <th style="padding: 8px;">
791 <input type="text" id="filterIpPrefix" placeholder="IP Prefix" style="padding: 5px 9px; border: 1px solid #888; border-radius: 3px; background-color: #5a5a5a; color: #ffffff; font-size: 0.9em; width: 100%; box-sizing: border-box;">
792 </th>
793 <th style="padding: 8px;">
794 <input type="text" id="filterCountry" placeholder="Country" style="padding: 5px 9px; border: 1px solid #888; border-radius: 3px; background-color: #5a5a5a; color: #ffffff; font-size: 0.9em; width: 100%; box-sizing: border-box;">
795 </th>
796 <th style="padding: 8px;">
797 <input type="text" id="filterRegion" placeholder="Region" style="padding: 5px 9px; border: 1px solid #888; border-radius: 3px; background-color: #5a5a5a; color: #ffffff; font-size: 0.9em; width: 100%; box-sizing: border-box;">
798 </th>
799 <th style="padding: 8px;">
800 <input type="text" id="filterCity" placeholder="City" style="padding: 5px 9px; border: 1px solid #888; border-radius: 3px; background-color: #5a5a5a; color: #ffffff; font-size: 0.9em; width: 100%; box-sizing: border-box;">
801 </th>
802 </tr>
803 </thead>
804 <tbody id="entriesTableBody">
805 {{range .Entries}}
806 <tr
807 id="csv-r-{{.Line}}"
808 class="expandable-row"
809 data-geocoding-hint="{{.GeocodingHint}}"
810 data-do-not-geolocate="{{.DoNotGeolocate}}"
811 data-has-warning="{{.HasWarning}}"
812 data-has-error="{{.HasError}}"
813 data-has-suggestion="{{.HasSuggestion}}"
814 data-tunable="{{.Tunable}}"
815 data-tuned-country="{{.TunedEntry.CountryCode}}"
816 data-tuned-region="{{.TunedEntry.RegionCode}}"
817 data-tuned-city="{{.TunedEntry.Name}}"
818 data-h3-cells="{{.TunedEntry.H3Cells}}"
819 data-bounding-box="{{.TunedEntry.BoundingBox}}">
820 <td><input type="checkbox" class="row-checkbox" checked></td>
821 <td>{{.Line}}</td>
822 <td><span class="status-badge status-{{if eq .Status "ERROR"}}error{{else if eq .Status "WARNING"}}warning{{else if eq .Status "SUGGESTION"}}suggestion{{else}}ok{{end}}"><i class="bi {{if eq .Status "ERROR"}}bi-x-circle-fill{{else if eq .Status "WARNING"}}bi-exclamation-triangle-fill{{else if eq .Status "SUGGESTION"}}bi-lightbulb-fill{{else}}bi-check-circle-fill{{end}}"></i>{{.Status}}</span></td>
823 <td><strong>{{.IPPrefix}}</strong></td>
824 <td>{{.CountryCode}}</td>
825 <td>{{.RegionCode}}</td>
826 <td>{{.City}}</td>
827 </tr>
828 <tr class="expand-details-row previous-values-row">
829 <td></td>
830 <td></td>
831 <td></td>
832 <td><strong>Previous values:</strong></td>
833 <td class="previous-value"><span class="default-country">{{.CountryCode}}</span></td>
834 <td class="previous-value"><span class="default-region">{{.RegionCode}}</span></td>
835 <td class="previous-value"><span class="default-city">{{.City}}</span></td>
836 </tr>
837 <tr class="expand-details-row issues-row">
838 <td></td>
839 <td></td>
840 <td colspan="5">
841 <div class="issues-header">
842 <strong>Issues:</strong>
843 {{if .Tunable}}
844 <button class="tune-all-btn" onclick="handleTuneButtonClick(this)">Tune</button>
845 {{end}}
846 </div>
847 {{range .Messages}}
848 <div class="message-line" data-id="{{.ID}}">
849 <span>{{.Text}}</span>
850 <input type="checkbox"{{if .Checked}} checked{{else}} disabled{{end}}>
851 </div>
852 {{end}}
853 </td>
854 </tr>
855 {{end}}
856 </tbody>
857 </table>
858
859 <!-- Pagination Controls -->
860 <div class="pagination-container">
861 <div class="pagination-info">
862 <span id="paginationInfo">Showing 0-0 of 0 rows</span>
863 </div>
864 <div class="pagination-controls">
865 <button class="pagination-btn" id="firstPage" title="First Page">
866 <i class="bi bi-chevron-bar-left"></i>
867 </button>
868 <button class="pagination-btn" id="prevPage" title="Previous Page">
869 <i class="bi bi-chevron-left"></i>
870 </button>
871 <div id="pageNumbers" style="display: flex; gap: 5px;"></div>
872 <button class="pagination-btn" id="nextPage" title="Next Page">
873 <i class="bi bi-chevron-right"></i>
874 </button>
875 <button class="pagination-btn" id="lastPage" title="Last Page">
876 <i class="bi bi-chevron-bar-right"></i>
877 </button>
878 </div>
879 <div class="page-size-selector">
880 <label for="pageSize" style="color: #333333; font-size: 0.9em;">Rows per page:</label>
881 <select id="pageSize">
882 <option value="100" selected>100</option>
883 <option value="all">All</option>
884 </select>
885 </div>
886 </div>
887 </div>
888
889 <div style="margin-top: 30px;">
890 <table class="metrics-table" style="margin-bottom: 0; width: 100%; border-bottom-left-radius: 0; border-bottom-right-radius: 0;">
891 <tr style="background-color: #6b6b6b;">
892 <td style="padding: 12px 15px; font-weight: bold; color: #ffffff; font-size: 1.1em; font-family: 'Raleway', sans-serif;">
893 <div style="display: flex; justify-content: space-between; align-items: center;">
894 <span><i class="bi bi-map-fill" style="margin-right: 10px;"></i>Geographic Coverage Map</span>
895 <div class="mode-toggle-container" style="font-size: 1em;">
896 <div class="mode-toggle" id="mapModeToggle" onclick="switchMapMode()" tabindex="0" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();switchMapMode();}" style="width: 170px;">
897 <span class="mode-toggle-text active" id="mapModeBboxText">BBox</span>
898 <div class="mode-toggle-slider" style="width: 81px;"></div>
899 <span class="mode-toggle-text" id="mapModeH3Text">H3 Cells</span>
900 </div>
901 </div>
902 </div>
903 </td>
904 </tr>
905 </table>
906 <div id="summaryMap" style="height: 480px; border: 1px solid #bbb; border-top: none; border-radius: 0 0 4px 4px;"></div>
907 </div>
908 </div>
909
910
911 <div id="locationModal" class="modal" tabindex="-1">
912 <div class="modal-content" tabindex="0">
913 <h2>
914 <i class="bi bi-geo-alt" style="margin-right: 10px;"></i>Location Matches
915 <span class="close" tabindex="0">×</span>
916 </h2>
917 <div id="matchesList" class="matches-container"></div>
918 </div>
919 </div>
920
921 <script>
922 /**
923 * Table cell indices for accessing column data
924 * @type {Object<string, number>}
925 */
926 // Constants
927 const CELL_INDEX = {
928 CHECKBOX: 0,
929 LINE: 1,
930 STATUS: 2,
931 IP_PREFIX: 3,
932 COUNTRY: 4,
933 REGION: 5,
934 CITY: 6,
935 MESSAGES: 7
936 };
937
938 /**
939 * Timing delays for UI interactions (in milliseconds)
940 * @type {Object<string, number>}
941 */
942 const TIMING = {
943 MAP_INIT_DELAY: 100,
944 MODAL_FOCUS_DELAY: 150,
945 MAP_RESIZE_DELAY: 200
946 };
947
948 /**
949 * Global state variables for modal and map management
950 * @type {Object}
951 */
952 // Global state
953 /** @type {Object<string, L.Map>} Maps indexed by mapId */
954 let maps = {};
955
956 /** @type {HTMLButtonElement|null} Currently active tune button reference */
957 let currentTuningButton = null;
958
959 /** @type {number} Currently selected match index in modal */
960 let selectedMatchIndex = 0;
961
962 /** @type {Array<HTMLElement>} Array of match item elements in modal */
963 let matchItems = [];
964
965 /** @type {boolean} Flag indicating if modal is currently open */
966 let isModalOpen = false;
967
968
969 let summaryMapInstance = null;
970 let summaryLayerGroup = null;
971 let currentMapMode = 'bbox';
972 let summaryRowData = [];
973
974 /**
975 * Stores CSV comment lines by line number for download functionality
976 * @type {Object<number, string>}
977 */
978 // Comment map: stores CSV comments by line number
979 const commentMap = {{.Comments}};
980
981 // Modal elements
982 const modal = document.getElementById('locationModal');
983 const modalContent = document.querySelector('.modal-content');
984 const closeBtn = document.querySelector('.close');
985
986 // Modal functions
987 closeBtn.onclick = function() {
988 closeModal();
989 };
990
991 closeBtn.addEventListener('keydown', function(e) {
992 if (e.key === 'Enter' || e.key === ' ') {
993 e.preventDefault();
994 closeModal();
995 }
996 });
997
998 window.onclick = function(event) {
999 if (event.target == modal) {
1000 closeModal();
1001 }
1002 };
1003
1004 /**
1005 * Closes the location matches modal and cleans up resources
1006 * @returns {void}
1007 */
1008 function closeModal() {
1009 if (!isModalOpen) return;
1010
1011 modal.style.display = 'none';
1012 isModalOpen = false;
1013 currentTuningButton = null;
1014 document.body.style.overflow = '';
1015
1016 Object.values(maps).forEach(map => {
1017 if (map && map.remove) {
1018 map.remove();
1019 }
1020 });
1021 maps = {};
1022 matchItems = [];
1023 }
1024
1025 // Modal keyboard navigation
1026 modal.addEventListener('keydown', function(event) {
1027 if (!isModalOpen) return;
1028
1029 if (event.key === 'Escape' || event.key === 'Esc') {
1030 event.preventDefault();
1031 closeModal();
1032 return;
1033 }
1034
1035 if (event.key === 'Enter') {
1036 event.preventDefault();
1037 if (matchItems[selectedMatchIndex]) {
1038 matchItems[selectedMatchIndex].click();
1039 }
1040 return;
1041 }
1042
1043 if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
1044 event.preventDefault();
1045 navigateMatches(-1);
1046 return;
1047 }
1048
1049 if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
1050 event.preventDefault();
1051 navigateMatches(1);
1052 return;
1053 }
1054
1055 if (event.key === '+' || event.key === '=') {
1056 event.preventDefault();
1057 zoomCurrentMap(1);
1058 return;
1059 }
1060
1061 if (event.key === '-' || event.key === '_') {
1062 event.preventDefault();
1063 zoomCurrentMap(-1);
1064 return;
1065 }
1066 });
1067
1068 document.addEventListener('keydown', function(event) {
1069 if ((event.key === 'Escape' || event.key === 'Esc') && isModalOpen) {
1070 event.preventDefault();
1071 closeModal();
1072 }
1073 });
1074
1075 /**
1076 * Navigates between location match items with arrow keys
1077 * @param {number} direction - Navigate direction: positive (right/down) or negative (left/up)
1078 * @returns {void}
1079 */
1080 function navigateMatches(direction) {
1081 if (matchItems.length === 0) return;
1082
1083 if (matchItems[selectedMatchIndex]) {
1084 matchItems[selectedMatchIndex].classList.remove('selected');
1085 }
1086
1087 selectedMatchIndex = (selectedMatchIndex + direction + matchItems.length) % matchItems.length;
1088
1089 if (matchItems[selectedMatchIndex]) {
1090 matchItems[selectedMatchIndex].classList.add('selected');
1091 matchItems[selectedMatchIndex].scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
1092 }
1093 }
1094
1095 /**
1096 * Zooms the currently selected map in or out
1097 * @param {number} direction - Zoom direction: positive to zoom in, negative to zoom out
1098 * @returns {void}
1099 */
1100 function zoomCurrentMap(direction) {
1101 const mapId = `map-${selectedMatchIndex}`;
1102 const map = maps[mapId];
1103 if (map) {
1104 const currentZoom = map.getZoom();
1105 map.setZoom(currentZoom + direction);
1106 }
1107 }
1108
1109 /**
1110 * API endpoint for place search functionality
1111 * @type {string}
1112 */
1113 const API_ENDPOINT = 'https://mcp.fastah.ai/rest/geofeeds/place-search';
1114
1115 /**
1116 * Fetches geolocation matches from the API for given location data
1117 * @async
1118 * @param {Array<Object>} rows - Array of row objects with location data (countryCode, regionCode, cityName)
1119 * @returns {Promise<{results: Array<Object>}|null>} API response with matches or null on error
1120 */
1121 async function fetchPlaceMatches(rows) {
1122 const MAX_BATCH_SIZE = 1000;
1123 let allResults = [];
1124 let batchCount = Math.ceil(rows.length / MAX_BATCH_SIZE);
1125 let lastError = null;
1126
1127 for (let i = 0; i < batchCount; i++) {
1128 const batchRows = rows.slice(i * MAX_BATCH_SIZE, (i + 1) * MAX_BATCH_SIZE);
1129 try {
1130 const response = await fetch(API_ENDPOINT, {
1131 method: 'POST',
1132 headers: {
1133 'Accept': 'application/json',
1134 'Content-Type': 'application/json'
1135 },
1136 body: JSON.stringify({ rows: batchRows })
1137 });
1138
1139 if (!response.ok) {
1140 throw new Error(`API request failed: ${response.status} ${response.statusText}`);
1141 }
1142
1143 const data = await response.json();
1144 if (data && data.results) {
1145 allResults = allResults.concat(data.results);
1146 } else {
1147 lastError = 'No results in API response';
1148 }
1149 } catch (error) {
1150 console.error('Error calling place search API:', error);
1151 lastError = error.message;
1152 }
1153 }
1154
1155 if (allResults.length === 0 && lastError) {
1156 alert(`Failed to fetch location data: ${lastError}`);
1157 return null;
1158 }
1159
1160 return { results: allResults };
1161 }
1162
1163 /**
1164 * Prepares geofeed row data for API request
1165 * @param {HTMLTableRowElement} dataRow - The table row element to extract data from
1166 * @returns {Object|null} Object with countryCode, regionCode, cityName, maxResults and searchMode, or null if required data missing
1167 */
1168 function prepareRowForApi(dataRow) {
1169 const cells = dataRow.querySelectorAll('td');
1170 if (cells.length <= CELL_INDEX.STATUS) return null;
1171
1172 const countryCode = cells[CELL_INDEX.COUNTRY].textContent.trim();
1173 const regionCode = cells[CELL_INDEX.REGION].textContent.trim();
1174 const cityName = cells[CELL_INDEX.CITY].textContent.trim();
1175
1176 if (!countryCode) {
1177 return null;
1178 }
1179
1180 return {
1181 rowKey: crypto.randomUUID(),
1182 countryCode: countryCode,
1183 regionCode: regionCode || '',
1184 cityName: cityName || '',
1185 searchMode: 'auto'
1186 };
1187 }
1188
1189 /**
1190 * Handles tune button click - fetches location matches for selected issues
1191 * @async
1192 * @param {HTMLButtonElement} button - The tune button that was clicked
1193 * @returns {Promise<void>}
1194 */
1195 async function handleTuneButtonClick(button) {
1196 currentTuningButton = button;
1197
1198 const issuesContainer = button.closest('td');
1199 const checkedBoxes = issuesContainer.querySelectorAll('.message-line input[type="checkbox"]:checked');
1200
1201 const selectedIssues = Array.from(checkedBoxes).map(cb => {
1202 const messageLine = cb.closest('.message-line');
1203 return {
1204 id: messageLine.getAttribute('data-id'),
1205 message: messageLine.querySelector('span').textContent
1206 };
1207 });
1208
1209 if (selectedIssues.length === 0) {
1210 alert('Please select at least one issue to tune.');
1211 return;
1212 }
1213
1214 const detailsRow = button.closest('tr');
1215
1216 // Find the data row - it's before the expand-details-rows
1217 let dataRow = detailsRow.previousElementSibling;
1218 while (dataRow && dataRow.classList.contains('expand-details-row')) {
1219 dataRow = dataRow.previousElementSibling;
1220 }
1221
1222 if (!dataRow || !dataRow.classList.contains('expandable-row')) {
1223 console.error('Could not find data row');
1224 return;
1225 }
1226
1227 const rowData = prepareRowForApi(dataRow);
1228 if (!rowData) {
1229 alert('Missing required location data for API call');
1230 return;
1231 }
1232
1233 const apiResponse = await fetchPlaceMatches([rowData]);
1234
1235 if (!apiResponse || !apiResponse.results || apiResponse.results.length === 0) {
1236 alert('No results returned from API');
1237 return;
1238 }
1239
1240 const result = apiResponse.results[0];
1241
1242 if (result.matches && result.matches.length > 0) {
1243 showLocationPopup(result.matches, selectedIssues);
1244 } else {
1245 alert('No location matches found for this entry');
1246 }
1247 }
1248
1249 /**
1250 * Displays the location matches popup modal with maps and selectable options
1251 * @param {Array<Object>} matches - Array of location match objects from API
1252 * @param {Array<Object>} [selectedIssues=[]] - Array of selected issues for tracking
1253 * @returns {void}
1254 */
1255 function showLocationPopup(matches, selectedIssues = []) {
1256 if (!matches || matches.length === 0) {
1257 return;
1258 }
1259
1260 const matchesList = document.getElementById('matchesList');
1261 matchesList.innerHTML = '';
1262
1263 maps = {};
1264 matchItems = [];
1265 selectedMatchIndex = 0;
1266
1267 matches.forEach((match, index) => {
1268 const matchItem = document.createElement('div');
1269 matchItem.className = 'match-item';
1270 if (index === 0) {
1271 matchItem.classList.add('selected');
1272 }
1273 const mapId = `map-${index}`;
1274
1275 // Determine primary location text: city > region > country
1276 let primaryLocation = match.placeName;
1277 let locationType = 'city';
1278
1279 if (!primaryLocation || primaryLocation.trim() === '') {
1280 primaryLocation = match.stateCode;
1281 locationType = 'region';
1282 }
1283
1284 if (!primaryLocation || primaryLocation.trim() === '') {
1285 primaryLocation = match.countryCode;
1286 locationType = 'country';
1287 }
1288
1289 // Build secondary info (show what's available)
1290 let secondaryInfo = [];
1291 if (match.placeName && locationType !== 'city') {
1292 secondaryInfo.push(`City: ${escapeHtml(match.placeName)}`);
1293 }
1294 if (match.stateCode && locationType !== 'region') {
1295 secondaryInfo.push(`Region: ${escapeHtml(match.stateCode)}`);
1296 }
1297 if (match.countryCode && locationType !== 'country') {
1298 secondaryInfo.push(`Country: ${escapeHtml(match.countryCode)}`);
1299 }
1300
1301 matchItem.innerHTML = `
1302 <div id="${mapId}" class="match-map"></div>
1303 <div class="match-info-container">
1304 <div class="match-header">${escapeHtml(primaryLocation)}</div>
1305 ${secondaryInfo.length > 0 ? `<div class="location-details">${secondaryInfo.join(' \u2022 ')}</div>` : ''}
1306 </div>
1307 `;
1308
1309 matchItem.addEventListener('click', function() {
1310 selectLocationMatch(match);
1311 });
1312
1313 matchesList.appendChild(matchItem);
1314 matchItems.push(matchItem);
1315
1316 setTimeout(() => {
1317 initializeMapForMatch(mapId, match);
1318 }, TIMING.MAP_INIT_DELAY);
1319 });
1320
1321 modal.style.display = 'block';
1322 isModalOpen = true;
1323 document.body.style.overflow = 'hidden';
1324
1325 setTimeout(() => {
1326 if (modalContent) {
1327 modalContent.focus();
1328 }
1329 }, TIMING.MODAL_FOCUS_DELAY);
1330 }
1331
1332 /**
1333 * Selects a location match and updates the corresponding table row
1334 * @param {Object} match - Match object with countryCode, regionCode, name, and boundingBox
1335 * @returns {void}
1336 */
1337 function selectLocationMatch(match) {
1338 if (!currentTuningButton) {
1339 console.error('No tuning button reference found');
1340 return;
1341 }
1342
1343 const detailsRow = currentTuningButton.closest('tr');
1344
1345 // Find the data row - it's before the expand-details-rows
1346 let dataRow = detailsRow.previousElementSibling;
1347 while (dataRow && dataRow.classList.contains('expand-details-row')) {
1348 dataRow = dataRow.previousElementSibling;
1349 }
1350
1351 if (!dataRow || !dataRow.classList.contains('expandable-row')) {
1352 console.error('Could not find data row');
1353 return;
1354 }
1355
1356 updateRowWithMatchData(dataRow, match);
1357 closeModal();
1358 }
1359
1360 /**
1361 * Updates a table row with selected match data and stores original values
1362 * @param {HTMLTableRowElement} dataRow - The table row to update
1363 * @param {Object} match - Match object with countryCode, regionCode, and name
1364 * @returns {void}
1365 */
1366 function updateRowWithMatchData(dataRow, match) {
1367 const cells = dataRow.querySelectorAll('td');
1368 const currentCountry = cells[CELL_INDEX.COUNTRY].textContent;
1369 const currentRegion = cells[CELL_INDEX.REGION].textContent;
1370 const currentCity = cells[CELL_INDEX.CITY].textContent;
1371
1372 if (!dataRow.hasAttribute('data-original-country')) {
1373 dataRow.setAttribute('data-original-country', currentCountry);
1374 dataRow.setAttribute('data-original-region', currentRegion);
1375 dataRow.setAttribute('data-original-city', currentCity);
1376 }
1377
1378 cells[CELL_INDEX.COUNTRY].textContent = match.countryCode;
1379 cells[CELL_INDEX.REGION].textContent = match.stateCode;
1380 cells[CELL_INDEX.CITY].textContent = match.placeName;
1381
1382 // Find the previous values row (it's the first expand-details-row after the data row)
1383 const previousValuesRow = dataRow.nextElementSibling;
1384 if (previousValuesRow && previousValuesRow.classList.contains('previous-values-row')) {
1385 previousValuesRow.classList.add('show');
1386
1387 const defaultCountry = previousValuesRow.querySelector('.default-country');
1388 const defaultRegion = previousValuesRow.querySelector('.default-region');
1389 const defaultCity = previousValuesRow.querySelector('.default-city');
1390
1391 if (defaultCountry) defaultCountry.textContent = dataRow.getAttribute('data-original-country');
1392 if (defaultRegion) defaultRegion.textContent = dataRow.getAttribute('data-original-region');
1393 if (defaultCity) defaultCity.textContent = dataRow.getAttribute('data-original-city');
1394 }
1395 }
1396
1397 /**
1398 * Initializes a Leaflet map with location boundary and marker for a match
1399 * @param {string} mapId - The DOM element ID where map will render
1400 * @param {Object} match - Match object with name, boundingBox, regionCode, countryCode
1401 * @returns {void}
1402 */
1403 function initializeMapForMatch(mapId, match) {
1404 try {
1405 const mapInstance = L.map(mapId, {
1406 scrollWheelZoom: false,
1407 dragging: true,
1408 zoomControl: true
1409 });
1410
1411 L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
1412 attribution: '© OpenStreetMap',
1413 maxZoom: 19
1414 }).addTo(mapInstance);
1415
1416 const bbox = match.boundingBox;
1417
1418 if (bbox && bbox.length === 4) {
1419 const [west, north, east, south] = bbox;
1420 const centerLat = (south + north) / 2;
1421 const centerLng = (west + east) / 2;
1422
1423 const isPoint = (west === east && north === south);
1424
1425 if (isPoint) {
1426 const offset = 0.05;
1427 const bounds = [
1428 [south - offset, west - offset],
1429 [north + offset, east + offset]
1430 ];
1431
1432 L.rectangle(bounds, {
1433 color: '#dc3545',
1434 weight: 3,
1435 opacity: 1,
1436 fillColor: '#dc3545',
1437 fillOpacity: 0.2
1438 }).addTo(mapInstance);
1439
1440 mapInstance.fitBounds(bounds, { padding: [30, 30] });
1441 } else {
1442 const bounds = [
1443 [south, west],
1444 [north, east]
1445 ];
1446
1447 L.rectangle(bounds, {
1448 color: '#dc3545',
1449 weight: 3,
1450 opacity: 1,
1451 fillColor: '#dc3545',
1452 fillOpacity: 0.2
1453 }).addTo(mapInstance);
1454
1455 mapInstance.fitBounds(bounds, { padding: [20, 20] });
1456 }
1457
1458 L.marker([centerLat, centerLng]).addTo(mapInstance)
1459 .bindPopup(`<b>${escapeHtml(match.placeName)}</b><br>${escapeHtml(match.stateCode)}`);
1460
1461 maps[mapId] = mapInstance;
1462
1463 setTimeout(() => {
1464 mapInstance.invalidateSize();
1465 }, TIMING.MAP_RESIZE_DELAY);
1466 }
1467 } catch (error) {
1468 console.error('Error initializing map:', error);
1469 }
1470 }
1471
1472 /**
1473 * Sets the report mode between 'viewing' and 'tuning'
1474 * Tuning mode shows checkboxes and expands rows with issues
1475 * @param {string} mode - 'viewing' or 'tuning'
1476 * @returns {void}
1477 */
1478 function setMode(mode) {
1479 const toggle = document.getElementById('modeToggle');
1480 const viewingText = document.getElementById('viewingText');
1481 const tuningText = document.getElementById('tuningText');
1482
1483 if (mode === 'tuning') {
1484 document.body.classList.add('tuning-mode');
1485 toggle.classList.add('tuning');
1486 viewingText.classList.remove('active');
1487 tuningText.classList.add('active');
1488
1489 // Only expand rows with issues
1490 const expandableRows = document.querySelectorAll('.expandable-row');
1491 expandableRows.forEach(row => {
1492 const hasError = row.getAttribute('data-has-error') === 'true';
1493 const hasWarning = row.getAttribute('data-has-warning') === 'true';
1494 const hasSuggestion = row.getAttribute('data-has-suggestion') === 'true';
1495
1496 if (hasError || hasWarning || hasSuggestion) {
1497 let detailRow = row.nextElementSibling;
1498 while (detailRow && detailRow.classList.contains('expand-details-row')) {
1499 // Only show issues-row, not previous-values-row
1500 if (detailRow.classList.contains('issues-row')) {
1501 detailRow.classList.add('show');
1502 }
1503 detailRow = detailRow.nextElementSibling;
1504 }
1505 }
1506 });
1507 } else {
1508 document.body.classList.remove('tuning-mode');
1509 toggle.classList.remove('tuning');
1510 viewingText.classList.add('active');
1511 tuningText.classList.remove('active');
1512 document.querySelectorAll('.expand-details-row').forEach(row => {
1513 row.classList.remove('show');
1514 });
1515 }
1516
1517 // Refresh pagination after mode change
1518 updatePagination();
1519 }
1520
1521 /**
1522 * Toggles between viewing and tuning modes
1523 * @returns {void}
1524 */
1525 function toggleMode() {
1526 const isTuning = document.body.classList.contains('tuning-mode');
1527 setMode(isTuning ? 'viewing' : 'tuning');
1528 }
1529
1530 /**
1531 * Escapes HTML special characters to prevent XSS attacks
1532 * @param {string} text - Text to escape
1533 * @returns {string} Escaped HTML text
1534 */
1535 function escapeHtml(text) {
1536 const map = {
1537 '&': '&',
1538 '<': '<',
1539 '>': '>',
1540 '"': '"',
1541 "'": '''
1542 };
1543 return text.replace(/[&<>"']/g, m => map[m]);
1544 }
1545
1546 /**
1547 * Filters the entries table based on current filter input values
1548 * Resets to page 1 and updates pagination
1549 * @returns {void}
1550 */
1551 function filterTable() {
1552 const countryFilter = document.getElementById('filterCountry').value.toLowerCase();
1553 const regionFilter = document.getElementById('filterRegion').value.toLowerCase();
1554 const cityFilter = document.getElementById('filterCity').value.toLowerCase();
1555 const statusFilter = document.getElementById('filterStatus').value;
1556 const ipPrefixFilter = document.getElementById('filterIpPrefix').value.toLowerCase();
1557
1558 const allExpandableRows = document.querySelectorAll('.expandable-row');
1559
1560 // Filter rows and update allRows array
1561 allRows = Array.from(allExpandableRows).filter(row => {
1562 const cells = row.querySelectorAll('td');
1563 if (cells.length <= CELL_INDEX.STATUS) return false;
1564
1565 const rowData = {
1566 country: cells[CELL_INDEX.COUNTRY].textContent.toLowerCase(),
1567 region: cells[CELL_INDEX.REGION].textContent.toLowerCase(),
1568 city: cells[CELL_INDEX.CITY].textContent.toLowerCase(),
1569 status: cells[CELL_INDEX.STATUS].textContent,
1570 ipPrefix: cells[CELL_INDEX.IP_PREFIX].textContent.toLowerCase()
1571 };
1572
1573 return (
1574 (!countryFilter || rowData.country.includes(countryFilter)) &&
1575 (!regionFilter || rowData.region.includes(regionFilter)) &&
1576 (!cityFilter || rowData.city.includes(cityFilter)) &&
1577 (!statusFilter || rowData.status === statusFilter) &&
1578 (!ipPrefixFilter || rowData.ipPrefix.includes(ipPrefixFilter))
1579 );
1580 });
1581
1582 // Reset to page 1 and update pagination
1583 currentPage = 1;
1584 updatePagination();
1585 }
1586
1587 /**
1588 * Pagination state and configuration
1589 * @type {Object}
1590 */
1591 // Pagination state
1592 /** @type {number} Current page number (1-indexed) */
1593 let currentPage = 1;
1594
1595 /** @type {number} Number of rows to display per page */
1596 let rowsPerPage = 100;
1597
1598 /** @type {Array<HTMLTableRowElement>} Array of expandable rows after filtering */
1599 let allRows = [];
1600
1601 // Initialize on page load
1602 window.addEventListener('DOMContentLoaded', function() {
1603 // Initialize pagination
1604 allRows = Array.from(document.querySelectorAll('.expandable-row'));
1605 initializePagination();
1606
1607 // Setup select all checkbox
1608 const selectAllCheckbox = document.getElementById('selectAll');
1609 const rowCheckboxes = document.querySelectorAll('.row-checkbox');
1610
1611 selectAllCheckbox.addEventListener('change', function() {
1612 rowCheckboxes.forEach(checkbox => {
1613 checkbox.checked = selectAllCheckbox.checked;
1614 });
1615 });
1616
1617 // Update select all checkbox when individual checkboxes change
1618 rowCheckboxes.forEach(checkbox => {
1619 checkbox.addEventListener('click', function(e) {
1620 e.stopPropagation(); // Prevent row expansion when clicking checkbox
1621 });
1622 checkbox.addEventListener('keydown', function(e) {
1623 if (e.key === ' ' || e.key === 'Enter') {
1624 e.stopPropagation(); // Prevent row expansion when using keyboard on checkbox
1625 }
1626 });
1627 checkbox.addEventListener('change', function(e) {
1628 e.stopPropagation(); // Prevent row expansion when clicking checkbox
1629 const allChecked = Array.from(rowCheckboxes).every(cb => cb.checked);
1630 const noneChecked = Array.from(rowCheckboxes).every(cb => !cb.checked);
1631 selectAllCheckbox.checked = allChecked;
1632 selectAllCheckbox.indeterminate = !allChecked && !noneChecked;
1633 });
1634 });
1635
1636 // Setup expandable rows
1637 const expandableRows = document.querySelectorAll('.expandable-row');
1638 expandableRows.forEach(row => {
1639 const hasError = row.getAttribute('data-has-error') === 'true';
1640 const hasWarning = row.getAttribute('data-has-warning') === 'true';
1641 const hasSuggestion = row.getAttribute('data-has-suggestion') === 'true';
1642
1643 // Only make rows with issues focusable
1644 if (hasError || hasWarning || hasSuggestion) {
1645 row.setAttribute('tabindex', '0');
1646
1647 // Click handler
1648 row.addEventListener('click', function(e) {
1649 // Don't expand if clicking on checkbox
1650 if (e.target.classList.contains('row-checkbox')) {
1651 return;
1652 }
1653 let detailRow = this.nextElementSibling;
1654 let expanding = false;
1655 // First pass: check if we are expanding (issues-row will be shown)
1656 while (detailRow && detailRow.classList.contains('expand-details-row')) {
1657 if (detailRow.classList.contains('issues-row')) {
1658 expanding = !detailRow.classList.contains('show');
1659 break;
1660 }
1661 detailRow = detailRow.nextElementSibling;
1662 }
1663 detailRow = this.nextElementSibling;
1664 while (detailRow && detailRow.classList.contains('expand-details-row')) {
1665 if (detailRow.classList.contains('issues-row')) {
1666 // Always toggle issues-row
1667 detailRow.classList.toggle('show');
1668 } else if (detailRow.classList.contains('previous-values-row')) {
1669 if (expanding) {
1670 // Show previous-values-row if this row has original data (was tuned)
1671 if (row.hasAttribute('data-original-country') ||
1672 row.hasAttribute('data-original-region') ||
1673 row.hasAttribute('data-original-city')) {
1674 detailRow.classList.add('show');
1675 }
1676 } else {
1677 // If collapsing, hide previous-values-row
1678 detailRow.classList.remove('show');
1679 }
1680 }
1681 detailRow = detailRow.nextElementSibling;
1682 }
1683 // Refresh pagination to show/hide the toggled detail rows
1684 updatePagination();
1685 });
1686
1687 // Keyboard handler
1688 row.addEventListener('keydown', function(e) {
1689 // Don't handle if focus is on checkbox
1690 if (document.activeElement.classList.contains('row-checkbox')) {
1691 return;
1692 }
1693 if (e.key === 'Enter' || e.key === ' ') {
1694 e.preventDefault();
1695 let detailRow = this.nextElementSibling;
1696 let expanding = false;
1697 // First pass: check if we are expanding (issues-row will be shown)
1698 while (detailRow && detailRow.classList.contains('expand-details-row')) {
1699 if (detailRow.classList.contains('issues-row')) {
1700 expanding = !detailRow.classList.contains('show');
1701 break;
1702 }
1703 detailRow = detailRow.nextElementSibling;
1704 }
1705 detailRow = this.nextElementSibling;
1706 while (detailRow && detailRow.classList.contains('expand-details-row')) {
1707 if (detailRow.classList.contains('issues-row')) {
1708 // Always toggle issues-row
1709 detailRow.classList.toggle('show');
1710 } else if (detailRow.classList.contains('previous-values-row')) {
1711 if (expanding) {
1712 // Show previous-values-row if this row has original data (was tuned)
1713 if (row.hasAttribute('data-original-country') ||
1714 row.hasAttribute('data-original-region') ||
1715 row.hasAttribute('data-original-city')) {
1716 detailRow.classList.add('show');
1717 }
1718 } else {
1719 // If collapsing, hide previous-values-row
1720 detailRow.classList.remove('show');
1721 }
1722 }
1723 detailRow = detailRow.nextElementSibling;
1724 }
1725 // Refresh pagination to show/hide the toggled detail rows
1726 updatePagination();
1727 }
1728 });
1729 } else {
1730 row.style.cursor = 'default';
1731 }
1732 });
1733
1734 // Toggle Expand All button
1735 let isExpanded = false;
1736 document.getElementById('toggleExpandAll').addEventListener('click', function() {
1737 const expandableRows = document.querySelectorAll('.expandable-row');
1738 const button = document.getElementById('toggleExpandAll');
1739
1740 if (!isExpanded) {
1741 // Show all
1742 expandableRows.forEach(row => {
1743 const checkbox = row.querySelector('.row-checkbox');
1744 if (!checkbox || !checkbox.checked) return; // Only work on checked rows
1745
1746 const hasError = row.getAttribute('data-has-error') === 'true';
1747 const hasWarning = row.getAttribute('data-has-warning') === 'true';
1748 const hasSuggestion = row.getAttribute('data-has-suggestion') === 'true';
1749
1750 // Only expand rows with issues
1751 if (hasError || hasWarning || hasSuggestion) {
1752 let detailRow = row.nextElementSibling;
1753 while (detailRow && detailRow.classList.contains('expand-details-row')) {
1754 // Show issues-row always
1755 if (detailRow.classList.contains('issues-row')) {
1756 detailRow.classList.add('show');
1757 } else if (detailRow.classList.contains('previous-values-row')) {
1758 // Show previous-values-row if this row has original data (was tuned)
1759 if (row.hasAttribute('data-original-country') ||
1760 row.hasAttribute('data-original-region') ||
1761 row.hasAttribute('data-original-city')) {
1762 detailRow.classList.add('show');
1763 }
1764 }
1765 detailRow = detailRow.nextElementSibling;
1766 }
1767 }
1768 });
1769 button.innerHTML = '<i class="bi bi-arrows-collapse" style="margin-right: 5px;"></i>Collapse Rows';
1770 isExpanded = true;
1771 } else {
1772 // Collapse all
1773 expandableRows.forEach(row => {
1774 const checkbox = row.querySelector('.row-checkbox');
1775 if (!checkbox || !checkbox.checked) return; // Only work on checked rows
1776
1777 let detailRow = row.nextElementSibling;
1778 while (detailRow && detailRow.classList.contains('expand-details-row')) {
1779 detailRow.classList.remove('show');
1780 detailRow = detailRow.nextElementSibling;
1781 }
1782 });
1783 button.innerHTML = '<i class="bi bi-arrows-expand" style="margin-right: 5px;"></i>Expand Rows';
1784 isExpanded = false;
1785 }
1786
1787 // Refresh pagination to show expanded rows correctly
1788 updatePagination();
1789 });
1790
1791 // Toggle Filters button
1792 document.getElementById('toggleFilters').addEventListener('click', function() {
1793 const filterRow = document.getElementById('filterRow');
1794 const button = document.getElementById('toggleFilters');
1795
1796 if (filterRow.style.display === 'none') {
1797 filterRow.style.display = '';
1798 button.innerHTML = '<i class="bi bi-funnel-fill" style="margin-right: 5px;"></i>Hide Filters';
1799 } else {
1800 filterRow.style.display = 'none';
1801 button.innerHTML = '<i class="bi bi-funnel-fill" style="margin-right: 5px;"></i>Show Filters';
1802 }
1803 });
1804
1805 // Tune All button
1806 document.getElementById('tuneAll').addEventListener('click', async function() {
1807 const expandableRows = document.querySelectorAll('.expandable-row');
1808
1809 let updatedCount = 0;
1810 let skippedCount = 0;
1811
1812 expandableRows.forEach(dataRow => {
1813 const checkbox = dataRow.querySelector('.row-checkbox');
1814 if (!checkbox || !checkbox.checked) {
1815 skippedCount++;
1816 return; // Only work on checked rows
1817 }
1818
1819 const isTunable = dataRow.getAttribute('data-tunable') === 'true';
1820 if (!isTunable) {
1821 skippedCount++;
1822 return;
1823 }
1824
1825 const tunedCountry = dataRow.getAttribute('data-tuned-country');
1826 const tunedRegion = dataRow.getAttribute('data-tuned-region');
1827 const tunedCity = dataRow.getAttribute('data-tuned-city');
1828
1829 if (!tunedCountry && !tunedRegion && !tunedCity) {
1830 skippedCount++;
1831 return;
1832 }
1833
1834 const cells = dataRow.querySelectorAll('td');
1835 if (cells.length <= CELL_INDEX.STATUS) {
1836 skippedCount++;
1837 return;
1838 }
1839
1840 if (!dataRow.hasAttribute('data-original-country')) {
1841 dataRow.setAttribute('data-original-country', cells[CELL_INDEX.COUNTRY].textContent);
1842 dataRow.setAttribute('data-original-region', cells[CELL_INDEX.REGION].textContent);
1843 dataRow.setAttribute('data-original-city', cells[CELL_INDEX.CITY].textContent);
1844 }
1845
1846 if (tunedCountry) cells[CELL_INDEX.COUNTRY].textContent = tunedCountry;
1847 if (tunedRegion) cells[CELL_INDEX.REGION].textContent = tunedRegion;
1848 if (tunedCity) cells[CELL_INDEX.CITY].textContent = tunedCity;
1849
1850 // Find the previous values row
1851 const previousValuesRow = dataRow.nextElementSibling;
1852 if (previousValuesRow && previousValuesRow.classList.contains('previous-values-row')) {
1853 previousValuesRow.classList.add('show');
1854
1855 const defaultCountry = previousValuesRow.querySelector('.default-country');
1856 const defaultRegion = previousValuesRow.querySelector('.default-region');
1857 const defaultCity = previousValuesRow.querySelector('.default-city');
1858
1859 if (defaultCountry) defaultCountry.textContent = dataRow.getAttribute('data-original-country');
1860 if (defaultRegion) defaultRegion.textContent = dataRow.getAttribute('data-original-region');
1861 if (defaultCity) defaultCity.textContent = dataRow.getAttribute('data-original-city');
1862 }
1863
1864 updatedCount++;
1865 });
1866
1867 // Refresh pagination to update displayed values
1868 updatePagination();
1869
1870 alert(`Successfully updated ${updatedCount} rows (${skippedCount} rows skipped - not tunable or no tuned values available)`);
1871 });
1872
1873 // Add filter event listeners
1874 document.getElementById('filterCountry').addEventListener('input', filterTable);
1875 document.getElementById('filterRegion').addEventListener('input', filterTable);
1876 document.getElementById('filterCity').addEventListener('input', filterTable);
1877 document.getElementById('filterStatus').addEventListener('change', filterTable);
1878 document.getElementById('filterIpPrefix').addEventListener('input', filterTable);
1879
1880 // Reset filters button
1881 document.getElementById('resetFilters').addEventListener('click', function() {
1882 document.getElementById('filterCountry').value = '';
1883 document.getElementById('filterRegion').value = '';
1884 document.getElementById('filterCity').value = '';
1885 document.getElementById('filterStatus').value = '';
1886 document.getElementById('filterIpPrefix').value = '';
1887
1888 // Reset to all rows and update pagination
1889 allRows = Array.from(document.querySelectorAll('.expandable-row'));
1890 currentPage = 1;
1891 updatePagination();
1892 });
1893
1894 // Download CSV button
1895 document.getElementById('downloadCSV').addEventListener('click', function() {
1896 const rows = document.querySelectorAll('.expandable-row');
1897 const rowDataMap = {};
1898
1899 // Collect all data rows with their line numbers
1900 rows.forEach(row => {
1901 const cells = row.querySelectorAll('td');
1902 if (cells.length > CELL_INDEX.CITY) {
1903 const lineNum = parseInt(cells[CELL_INDEX.LINE].textContent.trim());
1904 const ipPrefix = cells[CELL_INDEX.IP_PREFIX].textContent.trim();
1905 const country = cells[CELL_INDEX.COUNTRY].textContent.trim();
1906 const region = cells[CELL_INDEX.REGION].textContent.trim();
1907 const city = cells[CELL_INDEX.CITY].textContent.trim();
1908
1909 rowDataMap[lineNum] = [ipPrefix, country, region, city];
1910 }
1911 });
1912
1913 // Build CSV content with comments in the correct positions
1914 const csvLines = [];
1915
1916 // Find max line number
1917 const allLineNumbers = Object.keys(rowDataMap).map(n => parseInt(n));
1918 const maxLine = Math.max(...allLineNumbers);
1919
1920 // Iterate through all line numbers from 1 to maxLine
1921 for (let lineNum = 1; lineNum <= maxLine; lineNum++) {
1922 if (lineNum in commentMap) {
1923 // This line is a comment
1924 csvLines.push(commentMap[lineNum]);
1925 } else if (rowDataMap[lineNum]) {
1926 // This line is a data row
1927 const row = rowDataMap[lineNum];
1928 const csvRow = row.map(cell => {
1929 // Escape quotes and wrap in quotes if contains comma, quote, or newline
1930 const cellStr = String(cell);
1931 if (cellStr.includes(',') || cellStr.includes('"') || cellStr.includes('\n')) {
1932 return '"' + cellStr.replace(/"/g, '""') + '"';
1933 }
1934 return cellStr;
1935 }).join(',');
1936 csvLines.push(csvRow);
1937 }
1938 // If neither comment nor data, skip the line (maintains original CSV structure)
1939 }
1940
1941 const csvContent = csvLines.join('\n');
1942
1943 // Create download link
1944 const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
1945 const link = document.createElement('a');
1946 const url = URL.createObjectURL(blob);
1947
1948 // Get filename from inputFileMetrics, ensure it has .csv extension
1949 let filename =
1950 document.getElementById('inputFileMetrics')
1951 .textContent
1952 .trim()
1953 .split(/[/\\]/)
1954 .pop() || 'geofeed_report.csv';
1955 if (!filename.toLowerCase().endsWith('.csv')) {
1956 filename += '.csv';
1957 }
1958
1959 link.setAttribute('href', url);
1960 link.setAttribute('download', filename);
1961 link.style.visibility = 'hidden';
1962 document.body.appendChild(link);
1963 link.click();
1964 document.body.removeChild(link);
1965 });
1966
1967 // Pagination event listeners
1968 document.getElementById('pageSize').addEventListener('change', function() {
1969 const value = this.value;
1970 if (value === 'all') {
1971 rowsPerPage = allRows.length;
1972 } else {
1973 rowsPerPage = parseInt(value);
1974 }
1975 currentPage = 1;
1976 updatePagination();
1977 });
1978
1979 document.getElementById('firstPage').addEventListener('click', function() {
1980 currentPage = 1;
1981 updatePagination();
1982 });
1983
1984 document.getElementById('prevPage').addEventListener('click', function() {
1985 if (currentPage > 1) {
1986 currentPage--;
1987 updatePagination();
1988 }
1989 });
1990
1991 document.getElementById('nextPage').addEventListener('click', function() {
1992 const totalPages = Math.ceil(allRows.length / rowsPerPage);
1993 if (currentPage < totalPages) {
1994 currentPage++;
1995 updatePagination();
1996 }
1997 });
1998
1999 document.getElementById('lastPage').addEventListener('click', function() {
2000 currentPage = Math.ceil(allRows.length / rowsPerPage);
2001 updatePagination();
2002 });
2003
2004 initSummaryMap();
2005 });
2006
2007 /**
2008 * Initializes pagination on page load
2009 * @returns {void}
2010 */
2011 function initializePagination() {
2012 updatePagination();
2013 }
2014
2015
2016 function initSummaryMap() {
2017 try {
2018 const summaryMapEl = document.getElementById('summaryMap');
2019 if (!summaryMapEl) return;
2020 summaryMapInstance = L.map('summaryMap', {
2021 scrollWheelZoom: true,
2022 dragging: true,
2023 zoomControl: true,
2024 center: [20, 0],
2025 zoom: 2
2026 });
2027 L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
2028 attribution: '\u00a9 <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
2029 maxZoom: 19
2030 }).addTo(summaryMapInstance);
2031 summaryLayerGroup = L.layerGroup().addTo(summaryMapInstance);
2032 // Parse and cache all row data once
2033 summaryRowData = [];
2034 const expandableRows = document.querySelectorAll('.expandable-row');
2035 expandableRows.forEach(row => {
2036 const cells = row.querySelectorAll('td');
2037 const hasError = row.getAttribute('data-has-error') === 'true';
2038 const hasWarning = row.getAttribute('data-has-warning') === 'true';
2039 const hasSugg = row.getAttribute('data-has-suggestion') === 'true';
2040 let color = '#28a745';
2041 if (hasError) color = '#dc3545';
2042 else if (hasWarning) color = '#fd7e14';
2043 else if (hasSugg) color = '#17a2b8';
2044 const country = cells[CELL_INDEX.COUNTRY] ? cells[CELL_INDEX.COUNTRY].textContent.trim() : '';
2045 const region = cells[CELL_INDEX.REGION] ? cells[CELL_INDEX.REGION].textContent.trim() : '';
2046 const city = cells[CELL_INDEX.CITY] ? cells[CELL_INDEX.CITY].textContent.trim() : '';
2047 const parts = [];
2048 if (city && region && country) {
2049 parts.push('<b>' + escapeHtml(city) + '</b>' + ' • ' + escapeHtml(region) + ' • ' + escapeHtml(country));
2050 } else if (city && country) {
2051 parts.push('<b>' + escapeHtml(city) + '</b>' + ' • ' + escapeHtml(country));
2052 } else if (region && country) {
2053 parts.push('<b>' + escapeHtml(region) + '</b>' + ' • ' + escapeHtml(country));
2054 } else if (country) {
2055 parts.push('<b>' + escapeHtml(country) + '</b>');
2056 }
2057 summaryRowData.push({
2058 rowId: row.id,
2059 bbox: row.getAttribute('data-bounding-box') || '',
2060 h3cells: row.getAttribute('data-h3-cells') || '',
2061 color: color,
2062 popup: parts.join('<br>')
2063 });
2064 });
2065 setTimeout(() => {
2066 summaryMapInstance.invalidateSize();
2067 switchMapMode('bbox');
2068 }, 350);
2069 } catch (err) {
2070 console.error('initSummaryMap error:', err);
2071 }
2072 }
2073
2074 function switchMapMode(mode) {
2075 const toggle = document.getElementById('mapModeToggle');
2076 const bboxText = document.getElementById('mapModeBboxText');
2077 const h3Text = document.getElementById('mapModeH3Text');
2078 // If called with no argument, act as a toggle
2079 if (mode === undefined) {
2080 mode = currentMapMode === 'bbox' ? 'h3' : 'bbox';
2081 }
2082 if (mode === 'h3') {
2083 toggle.classList.add('h3mode');
2084 bboxText.classList.remove('active');
2085 h3Text.classList.add('active');
2086 } else {
2087 toggle.classList.remove('h3mode');
2088 bboxText.classList.add('active');
2089 h3Text.classList.remove('active');
2090 }
2091 renderSummaryMapLayers(mode);
2092 }
2093
2094 function renderSummaryMapLayers(mode) {
2095 if (!summaryMapInstance || !summaryLayerGroup) return;
2096 currentMapMode = mode;
2097 summaryLayerGroup.clearLayers();
2098
2099 // Determine which row IDs are visible on the current page
2100 const startIndex = (currentPage - 1) * rowsPerPage;
2101 const endIndex = Math.min(startIndex + rowsPerPage, allRows.length);
2102 const visibleIds = new Set();
2103 for (let i = startIndex; i < endIndex; i++) {
2104 visibleIds.add(allRows[i].id);
2105 }
2106
2107 const tempBoundsLayers = [];
2108 const seenBboxKeys = new Set(); // deduplicate identical bounding boxes / h3 cell sets
2109
2110 summaryRowData.forEach(data => {
2111 // Only render entries whose row is on the current page
2112 if (!visibleIds.has(data.rowId)) return;
2113
2114 if (mode === 'bbox') {
2115 const raw = data.bbox.replace(/[\[\]]/g, '').trim();
2116 const pts = raw.split(/[\s,]+/).map(Number);
2117 if (pts.length !== 4 || pts.some(v => isNaN(v))) return;
2118
2119 // Skip duplicate bounding boxes
2120 const bboxKey = pts.join(',');
2121 if (seenBboxKeys.has(bboxKey)) return;
2122 seenBboxKeys.add(bboxKey);
2123
2124 const [west, north, east, south] = pts;
2125 const lb = [[south, west], [north, east]];
2126 const rect = L.rectangle(lb, {
2127 color: data.color, weight: 3, opacity: 1,
2128 fillColor: data.color, fillOpacity: 0.35
2129 });
2130 rect.bindPopup(data.popup);
2131 summaryLayerGroup.addLayer(rect);
2132 const cm = L.circleMarker([(south + north) / 2, (west + east) / 2], {
2133 radius: 5, color: data.color, fillColor: data.color, fillOpacity: 0.9, weight: 1
2134 });
2135 cm.bindPopup(data.popup);
2136 summaryLayerGroup.addLayer(cm);
2137 tempBoundsLayers.push(L.rectangle(lb));
2138 } else {
2139 // H3 cells mode
2140 const raw = data.h3cells.replace(/[\[\]]/g, '').trim();
2141 if (!raw) return;
2142
2143 // Skip duplicate h3 cell sets
2144 if (seenBboxKeys.has(raw)) return;
2145 seenBboxKeys.add(raw);
2146
2147 const cellIds = raw.split(/[\s,]+/).filter(Boolean);
2148 cellIds.forEach(cellId => {
2149 try {
2150 const boundary = h3.cellToBoundary(cellId); // returns [[lat,lng],...]
2151 const poly = L.polygon(boundary, {
2152 color: data.color, weight: 2, opacity: 0.9,
2153 fillColor: data.color, fillOpacity: 0.35
2154 });
2155 poly.bindPopup(data.popup);
2156 summaryLayerGroup.addLayer(poly);
2157 const b = poly.getBounds();
2158 tempBoundsLayers.push(L.rectangle([[b.getSouth(), b.getWest()], [b.getNorth(), b.getEast()]]));
2159 } catch (e) {
2160 console.warn('H3 render error for cell', cellId, e);
2161 }
2162 });
2163 }
2164 });
2165 if (tempBoundsLayers.length > 0) {
2166 const group = L.featureGroup(tempBoundsLayers);
2167 summaryMapInstance.fitBounds(group.getBounds(), { padding: [40, 40], maxZoom: 14 });
2168 }
2169 }
2170
2171
2172 /**
2173 * Updates pagination display for current page
2174 * Shows/hides rows based on current page and rowsPerPage
2175 * @returns {void}
2176 */
2177 function updatePagination() {
2178 const totalPages = Math.max(1, Math.ceil(allRows.length / rowsPerPage));
2179
2180 // Ensure currentPage is within valid range
2181 if (currentPage > totalPages) {
2182 currentPage = totalPages;
2183 }
2184 if (currentPage < 1) {
2185 currentPage = 1;
2186 }
2187
2188 const startIndex = (currentPage - 1) * rowsPerPage;
2189 const endIndex = Math.min(startIndex + rowsPerPage, allRows.length);
2190
2191 // Hide ALL expandable rows first (not just filtered ones)
2192 const allExpandableRows = document.querySelectorAll('.expandable-row');
2193 allExpandableRows.forEach(row => {
2194 row.style.display = 'none';
2195 // Hide associated detail rows
2196 let nextRow = row.nextElementSibling;
2197 while (nextRow && nextRow.classList.contains('expand-details-row')) {
2198 nextRow.style.display = 'none';
2199 nextRow = nextRow.nextElementSibling;
2200 }
2201 });
2202
2203 // Show rows for current page
2204 for (let i = startIndex; i < endIndex; i++) {
2205 const row = allRows[i];
2206 row.style.display = '';
2207 // Show associated detail rows if they have 'show' class
2208 let nextRow = row.nextElementSibling;
2209 while (nextRow && nextRow.classList.contains('expand-details-row')) {
2210 if (nextRow.classList.contains('show')) {
2211 nextRow.style.display = '';
2212 }
2213 nextRow = nextRow.nextElementSibling;
2214 }
2215 }
2216
2217 // Update pagination info
2218 if (allRows.length === 0) {
2219 document.getElementById('paginationInfo').textContent = 'Showing 0 of 0 rows';
2220 } else {
2221 document.getElementById('paginationInfo').textContent =
2222 `Showing ${startIndex + 1}-${endIndex} of ${allRows.length} rows`;
2223 }
2224
2225 // Update button states
2226 document.getElementById('firstPage').disabled = currentPage === 1 || allRows.length === 0;
2227 document.getElementById('prevPage').disabled = currentPage === 1 || allRows.length === 0;
2228 document.getElementById('nextPage').disabled = currentPage === totalPages || allRows.length === 0;
2229 document.getElementById('lastPage').disabled = currentPage === totalPages || allRows.length === 0;
2230
2231 // Update page numbers
2232 updatePageNumbers(totalPages);
2233
2234 // Refresh the Geographic Coverage Map to show only current-page entries
2235 renderSummaryMapLayers(currentMapMode);
2236 }
2237
2238 /**
2239 * Updates page number buttons display with ellipsis for large page counts
2240 * @param {number} totalPages - Total number of pages available
2241 * @returns {void}
2242 */
2243 function updatePageNumbers(totalPages) {
2244 const pageNumbersContainer = document.getElementById('pageNumbers');
2245 pageNumbersContainer.innerHTML = '';
2246
2247 // Show max 5 page numbers with ellipsis
2248 const maxVisible = 5;
2249 let startPage = Math.max(1, currentPage - Math.floor(maxVisible / 2));
2250 let endPage = Math.min(totalPages, startPage + maxVisible - 1);
2251
2252 // Adjust if we're near the end
2253 if (endPage - startPage < maxVisible - 1) {
2254 startPage = Math.max(1, endPage - maxVisible + 1);
2255 }
2256
2257 // Add first page and ellipsis if needed
2258 if (startPage > 1) {
2259 addPageButton(1);
2260 if (startPage > 2) {
2261 const ellipsis = document.createElement('span');
2262 ellipsis.textContent = '...';
2263 ellipsis.style.padding = '6px 8px';
2264 ellipsis.style.color = '#333';
2265 pageNumbersContainer.appendChild(ellipsis);
2266 }
2267 }
2268
2269 // Add page numbers
2270 for (let i = startPage; i <= endPage; i++) {
2271 addPageButton(i);
2272 }
2273
2274 // Add ellipsis and last page if needed
2275 if (endPage < totalPages) {
2276 if (endPage < totalPages - 1) {
2277 const ellipsis = document.createElement('span');
2278 ellipsis.textContent = '...';
2279 ellipsis.style.padding = '6px 8px';
2280 ellipsis.style.color = '#333';
2281 pageNumbersContainer.appendChild(ellipsis);
2282 }
2283 addPageButton(totalPages);
2284 }
2285 }
2286
2287 /**
2288 * Creates and adds a page number button to pagination controls
2289 * @param {number} pageNum - Page number for the button
2290 * @returns {void}
2291 */
2292 function addPageButton(pageNum) {
2293 const pageNumbersContainer = document.getElementById('pageNumbers');
2294 const btn = document.createElement('button');
2295 btn.className = 'pagination-btn' + (pageNum === currentPage ? ' active' : '');
2296 btn.textContent = pageNum;
2297 btn.addEventListener('click', function() {
2298 currentPage = pageNum;
2299 updatePagination();
2300 });
2301 pageNumbersContainer.appendChild(btn);
2302 }
2303 </script>
2304</body>
2305</html>