-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathaprs-map.php
More file actions
1553 lines (1477 loc) · 77.2 KB
/
Copy pathaprs-map.php
File metadata and controls
1553 lines (1477 loc) · 77.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
<?php
/**
* NewUI v4.0 — APRS-IS Map (Phase 99g, 2026-06-28).
*
* Live map view of stations heard via APRS-IS. Reuses existing
* Leaflet stack from index.php. Updates every 60 seconds.
*
* Sends path (Phase 99a/14) is already wired through Compose form.
* Receive listener daemon is a separate piece (Python service that
* subscribes to APRS-IS and writes into location_reports). This page
* shows whatever's been written there + clearly indicates listener
* status so admins know if rows aren't coming in.
*
* Auth: any logged-in user (matches the dispatch-map permission posture).
*/
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/inc/i18n.php';
require_once __DIR__ . '/inc/rbac.php';
// 2026-07-04 (GH #13) — pick the session profile matching the
// client's cookie (TCADMOBILE vs PHPSESSID). Without this, a
// browser holding a mobile cookie opens an empty desktop session
// here and bounces to login -> redirect loop.
require_once __DIR__ . '/inc/session-bootstrap.php';
sess_bootstrap_auto();
session_start();
if (empty($_SESSION['user_id'])) {
header('Location: login.php');
exit;
}
require_once __DIR__ . '/inc/force-pw-change.php';
force_pw_change_redirect();
$user = e($_SESSION['user']);
$level = current_role_name();
$theme = $_SESSION['day_night'] ?? 'Day';
$bs_theme = ($theme === 'Night') ? 'dark' : 'light';
$csrf = csrf_token();
$active_page = 'aprs-map';
?>
<!DOCTYPE html>
<html lang="<?php echo e(i18n_lang()); ?>" data-bs-theme="<?php echo $bs_theme; ?>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="<?php echo $csrf; ?>">
<title>APRS-IS Map — <?php echo e(t('login.title', 'Tickets NewUI')); ?> <?php echo newui_version(); ?></title>
<link rel="stylesheet" href="assets/vendor/bootstrap/bootstrap.min.css">
<link rel="stylesheet" href="assets/vendor/bootstrap/bootstrap-icons.min.css">
<link rel="stylesheet" href="assets/vendor/leaflet/leaflet.css">
<link rel="stylesheet" href="assets/vendor/gridstack/gridstack.min.css">
<link rel="stylesheet" href="assets/vendor/gridstack/gridstack-extra.min.css">
<link rel="stylesheet" href="assets/css/dashboard.css?v=<?php echo newui_version(); ?>">
<style>
/* Phase 99h-v5 (2026-06-29) — GridStack widget layout.
Eric beta: "full widget style layout, like the situation
page. I might have a very wide monitor for example, and
want to place this in the upper 1/3 of that wide monitor".
Each widget (Map / Stations) is draggable + resizable by
the .card-header grip; positions + sizes persist per-user
via localStorage. */
#aprsGrid {
background: transparent;
}
.grid-stack-item-content {
display: flex;
flex-direction: column;
overflow: hidden;
border-radius: 0.375rem;
background: var(--bs-body-bg);
border: 1px solid var(--bs-border-color);
}
.grid-stack-item-content > .card-header {
cursor: move;
padding: 4px 10px;
border-bottom: 1px solid var(--bs-border-color);
background: var(--bs-tertiary-bg);
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.grid-stack-item-content > .card-body {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
#aprsMap {
flex: 1 1 auto;
min-height: 0;
}
.aprs-widget-body-list {
padding: 8px;
overflow: visible !important;
}
.aprs-widget-body-list .table-responsive {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
}
/* Types-filter dropdown — escape its widget pane */
#aprsTypeFilterDropdown {
z-index: 1080;
}
/* Phase 99h-v5 fix (2026-06-29): override GridStack's default
SE resize handle. Eric beta reported a blue `>` floating
in the corner — that was GridStack's chevron pseudo-element
("ui-resizable-se::before" content) painted through our
border overrides. Replace with a larger, easier-to-grab
corner triangle that has no pseudo content of its own.
Also bigger hit area (24px) so the user can grab it
without precision pixel-hunting. */
.grid-stack-item > .ui-resizable-se {
width: 24px !important;
height: 24px !important;
right: 0 !important;
bottom: 0 !important;
background: none !important;
background-image: none !important;
transform: none !important;
border: none !important;
opacity: 0.55;
transition: opacity 0.15s;
}
.grid-stack-item > .ui-resizable-se::before,
.grid-stack-item > .ui-resizable-se::after {
content: none !important;
display: none !important;
}
/* Diagonal stripe pattern as the visible grip — non-clickable
on its own, the parent .ui-resizable-se is what catches
the drag, so the visual sits over the whole hit area. */
.grid-stack-item > .ui-resizable-se {
background-image:
linear-gradient(135deg,
transparent 0%, transparent 40%,
var(--bs-border-color) 40%, var(--bs-border-color) 45%,
transparent 45%, transparent 55%,
var(--bs-border-color) 55%, var(--bs-border-color) 60%,
transparent 60%, transparent 75%,
var(--bs-border-color) 75%, var(--bs-border-color) 80%,
transparent 80%) !important;
}
.grid-stack-item:hover > .ui-resizable-se {
opacity: 1;
background-image:
linear-gradient(135deg,
transparent 0%, transparent 40%,
var(--bs-primary) 40%, var(--bs-primary) 45%,
transparent 45%, transparent 55%,
var(--bs-primary) 55%, var(--bs-primary) 60%,
transparent 60%, transparent 75%,
var(--bs-primary) 75%, var(--bs-primary) 80%,
transparent 80%) !important;
}
.aprs-row-clickable { cursor: pointer; }
.aprs-row-clickable:hover { background: var(--bs-primary-bg-subtle) !important; }
.aprs-row-active { background: var(--bs-primary-bg-subtle) !important; }
.aprs-age-fresh { color: var(--bs-success); }
.aprs-age-stale { color: var(--bs-warning); }
.aprs-age-cold { color: var(--bs-secondary); }
/* Columns dropdown */
#aprsColsDropdown { min-width: 220px; max-height: 60vh; overflow-y: auto; }
#aprsColsDropdown .aprs-col-item {
padding: 4px 12px; cursor: grab;
display: flex; align-items: center; gap: 6px;
}
#aprsColsDropdown .aprs-col-item:hover { background: var(--bs-tertiary-bg); }
#aprsColsDropdown .aprs-col-item.dragging { opacity: 0.4; }
#aprsColsDropdown .aprs-col-item.drag-over { border-top: 2px solid var(--bs-primary); }
#aprsColsDropdown .aprs-col-grip { color: var(--bs-body-tertiary); font-size: 0.85rem; }
</style>
</head>
<body>
<?php include_once NEWUI_ROOT . '/inc/navbar.php'; ?>
<div class="container-fluid mt-3">
<div class="d-flex align-items-center gap-3 mb-3">
<h5 class="mb-0">
<i class="bi bi-broadcast-pin me-1 text-primary"></i> APRS-IS Map
</h5>
<span id="aprsStatusBadge" class="badge bg-secondary">Loading…</span>
<span id="aprsStationCount" class="text-body-secondary small"></span>
<!-- Phase 99h-v2 (2026-06-29) — replaced Map/List view toggle
with a single split layout (map + table simultaneously
visible). The old toggle is gone — both panes are always
present; the drag handle between them lets the user
resize. Use the Columns dropdown below the map to hide
columns if more map area is wanted. -->
<div class="btn-group btn-group-sm ms-2" role="group">
<button type="button" class="btn btn-outline-secondary" id="aprsColsBtn"
data-bs-toggle="dropdown" aria-expanded="false"
title="Toggle visible columns + drag-reorder">
<i class="bi bi-layout-three-columns me-1"></i>Columns
</button>
<ul class="dropdown-menu" id="aprsColsDropdown" data-bs-auto-close="outside">
<li><h6 class="dropdown-header small">Show / reorder columns</h6></li>
<li><div id="aprsColsList"><!-- populated by JS --></div></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item small" href="#" id="aprsColsReset">
<i class="bi bi-arrow-counterclockwise me-1"></i>Reset to defaults
</a></li>
</ul>
</div>
<!-- Motion projection toggle (Eric beta 2026-06-29):
dead-reckon moving stations between server refreshes.
Pure client-side — zero server load. Stops animating
stations whose last fix is >5 min old. Preference
persisted in localStorage. -->
<!-- Phase 99h-v5 (2026-06-29) — Eric beta: configurable
motion-projection timeout (was hardcoded 15 min). Type
any number of minutes; the projection animates a moving
station forward from its last fix up to this many
minutes, then freezes at that extrapolated position. -->
<div class="form-check form-switch ms-2 mb-0 small" title="Animate moving stations between updates using last known speed + heading.">
<input class="form-check-input" type="checkbox" id="aprsMotionToggle">
<label class="form-check-label" for="aprsMotionToggle">
<i class="bi bi-arrow-right-circle me-1"></i>Motion
</label>
</div>
<div class="d-flex align-items-center gap-1 ms-1 small" title="Motion projection timeout in minutes — markers freeze at the position they'd be at this many minutes after their last fix. Default 15.">
<input type="number" class="form-control form-control-sm" id="aprsMotionTimeout"
min="1" max="240" step="1" value="15"
style="width:60px;" aria-label="Motion projection timeout in minutes">
<span class="text-body-secondary" style="font-size:0.7rem;">min</span>
</div>
<!-- Watchlist layer toggle (Eric beta 2026-06-29):
system-wide curated list of "interesting" APRS callsigns
(admin-managed via Watch column in List view). Each user
toggles visibility of the layer in their own view.
Hidden = all markers render as normal. Visible = watched
stations get a gold star + thicker border for visual
distinction (non-watched remain in their normal style). -->
<div class="form-check form-switch ms-2 mb-0 small" title="Filter map + table to ONLY stations on the watchlist. Admins curate the list via the Watched checkbox in the table.">
<!-- Phase 99h-v2 fix (2026-06-29): removed default `checked`
attribute. Was leaving a mismatch with JS that sets
toggle.checked from localStorage (defaults to false) —
race window during init where renderStations could
read the still-checked DOM and apply an unintended
filter. -->
<input class="form-check-input" type="checkbox" id="aprsWatchLayerToggle">
<label class="form-check-label" for="aprsWatchLayerToggle">
<i class="bi bi-star-fill text-warning me-1"></i>Watched only
<span class="badge bg-secondary ms-1" id="aprsWatchCount" style="font-size:0.6rem;">0</span>
</label>
</div>
<div class="ms-auto d-flex align-items-center gap-2">
<label class="form-label form-label-sm mb-0 text-body-secondary" for="aprsSinceMin">Time window:</label>
<select class="form-select form-select-sm" id="aprsSinceMin" style="width:120px;">
<option value="15">Last 15 min</option>
<option value="60" selected>Last hour</option>
<option value="240">Last 4 h</option>
<option value="1440">Last 24 h</option>
</select>
<button type="button" class="btn btn-sm btn-outline-secondary" id="aprsRefreshBtn"
title="Refresh now (auto-refreshes every 60s)">
<i class="bi bi-arrow-clockwise"></i>
</button>
<a href="settings.php#aprs-config" class="btn btn-sm btn-outline-primary"
title="Open APRS-IS settings">
<i class="bi bi-gear me-1"></i>Settings
</a>
</div>
</div>
<!-- Listener-not-running banner. Hidden when listener is healthy. -->
<div id="aprsListenerWarning" class="alert alert-warning d-none mb-3" role="alert">
<i class="bi bi-exclamation-triangle-fill me-2"></i>
<strong>APRS-IS receive listener is not active.</strong>
<span id="aprsListenerDetail" class="small ms-1"></span>
The map shows whatever was last received; new stations won't appear
until the listener service is running. See
<a href="docs/LOCATION-PROVIDERS-GUIDE.md" target="_blank" class="alert-link">Location Providers Guide</a>
for setup instructions.
</div>
<!-- Phase 99h-v5 (2026-06-29) — Eric beta: GridStack widget
layout. Two widgets (Map + Stations) each draggable by
their card header, resizable by their bottom-right corner.
User layout persists in localStorage. -->
<div class="grid-stack" id="aprsGrid">
<div class="grid-stack-item" gs-id="map" gs-x="0" gs-y="0" gs-w="12" gs-h="7" gs-min-w="3" gs-min-h="3">
<div class="grid-stack-item-content">
<div class="card-header">
<i class="bi bi-grip-vertical text-body-tertiary"></i>
<i class="bi bi-map-fill text-primary"></i>
<strong class="small">APRS Map</strong>
<span class="ms-auto text-body-secondary" style="font-size:0.7rem;">
<span id="aprsStationCountWidget"></span>
</span>
</div>
<div class="card-body p-0"><div id="aprsMap"></div></div>
</div>
</div>
<div class="grid-stack-item" gs-id="table" gs-x="0" gs-y="7" gs-w="12" gs-h="8" gs-min-w="4" gs-min-h="3">
<div class="grid-stack-item-content">
<div class="card-header">
<i class="bi bi-grip-vertical text-body-tertiary"></i>
<i class="bi bi-table text-primary"></i>
<strong class="small">Stations</strong>
<span class="ms-auto text-body-secondary" style="font-size:0.7rem;" id="aprsListShownCountInline"></span>
</div>
<div class="card-body aprs-widget-body-list">
<div class="d-flex align-items-center gap-2 mb-2">
<input type="search" class="form-control form-control-sm" id="aprsListFilter"
placeholder="Filter rows (callsign, comment, type)…" style="max-width:300px;" aria-label="Filter rows by callsign, comment, or type">
<!-- Phase 99h-v3 (2026-06-29) — Eric beta: filter by
station type (e.g. hide all weather, all houses).
Dropdown lists every type currently in the data;
unchecking hides that type from BOTH the table
and the map. Persists in localStorage as a deny-
list (so new types default to visible). -->
<div class="btn-group btn-group-sm">
<button type="button" class="btn btn-outline-secondary"
data-bs-toggle="dropdown" data-bs-auto-close="outside"
id="aprsTypeFilterBtn">
<i class="bi bi-funnel me-1"></i>Types <span id="aprsTypeFilterLabel">(all)</span>
</button>
<ul class="dropdown-menu" id="aprsTypeFilterDropdown" style="min-width:220px;max-height:60vh;overflow-y:auto;">
<li><h6 class="dropdown-header small">Show / hide by station type</h6></li>
<li><div id="aprsTypeFilterList"><!-- populated by JS --></div></li>
<li><hr class="dropdown-divider"></li>
<li class="d-flex gap-2 px-2 pb-1">
<a class="dropdown-item small flex-grow-1 text-start" href="#" id="aprsTypeFilterAll">
<i class="bi bi-check-all me-1"></i>Show all
</a>
<a class="dropdown-item small flex-grow-1 text-start" href="#" id="aprsTypeFilterNone">
<i class="bi bi-eye-slash me-1"></i>Hide all
</a>
</li>
</ul>
</div>
<span class="text-body-secondary small ms-auto" id="aprsListShownCount"></span>
</div>
<div class="table-responsive">
<table class="table table-sm table-hover table-striped mb-0">
<!-- Phase 99h-v2 (2026-06-29) — thead is JS-rendered
so column visibility + reorder can be applied.
Fallback <th> cells below cover the pre-JS render
window for screen readers + satisfy Sonar Web:S5256
which can't see the runtime replacement. JS
overwrites this whole <tr> on load. -->
<thead class="table-light sticky-top" style="top:0;">
<tr id="aprsListHead">
<th scope="col">Callsign</th>
<th scope="col">Type</th>
<th scope="col">Last heard</th>
</tr>
</thead>
<tbody id="aprsListBody">
<tr><td class="text-center text-body-secondary py-3">Loading…</td></tr>
</tbody>
</table>
</div>
</div>
<p class="text-body-secondary small mt-2 mb-0">
<i class="bi bi-info-circle me-1"></i>
Color-coded by age (green <5min, yellow 5-30min, grey older).
<strong>Click any table row</strong> to zoom map + open popup.
<strong>Drag widgets</strong> by their header bar;
<strong>resize</strong> from the bottom-right corner.
<a href="#" id="aprsResetLayout" class="ms-2">Reset layout</a>
</p>
</div>
<script src="assets/vendor/bootstrap/bootstrap.bundle.min.js"></script>
<script src="assets/vendor/leaflet/leaflet.js"></script>
<script src="assets/vendor/gridstack/gridstack-all.js"></script>
<script>
(function () {
'use strict';
var map = null;
var markers = {}; // callsign → L.Marker
var stations = [];
var refreshTimer = null;
// Phase 99h watchlist state — Set of uppercase callsigns + admin-write flag
var watchSet = new Set();
var watchCanWrite = false;
function init() {
// Phase 103c (a beta tester GH #3) — respect Settings > Map Defaults
// instead of the historical Twin Cities constant. We initialise
// with a placeholder view and immediately overwrite it with the
// configured lat/lng/zoom once /api/map-config.php responds.
// Falls back to Twin Cities if the endpoint fails so the map
// never renders at the (0,0) equator.
map = L.map('aprsMap', { zoomControl: true }).setView([44.95, -93.10], 9);
fetch('api/map-config.php', { credentials: 'same-origin' })
.then(function (r) { return r.json(); })
.then(function (cfg) {
if (!cfg || typeof cfg.def_lat !== 'number' || typeof cfg.def_lng !== 'number') return;
map.setView([cfg.def_lat, cfg.def_lng], cfg.def_zoom || 9);
})
.catch(function () { /* fall back silently to Twin Cities */ });
// 2026-06-29 (Eric beta) — basemap selector. Adds a layers
// control to the map's top-right with multiple tile providers.
// User pick persisted in localStorage. Default = Streets (OSM).
var streets = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19, attribution: '© OpenStreetMap'
});
var satellite = L.tileLayer(
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
{ maxZoom: 19, attribution: 'Tiles © Esri' }
);
// Phase 99h-v5 fix (2026-06-29): switched from OpenTopoMap
// (CORS/rate-limit issues on training's outbound traffic —
// Eric beta reported the topo layer wasn't rendering) to
// Esri's World Topographic Map service, which is free, no
// auth, no rate-limit for normal use, and CORS-friendly.
var topo = L.tileLayer(
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}',
{ maxZoom: 19, attribution: 'Tiles © Esri — Sources: National Geographic, Esri, DeLorme, etc.' }
);
var hybrid_labels = L.tileLayer(
'https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}',
{ maxZoom: 19, attribution: '© Esri', opacity: 0.85 }
);
var satelliteHybrid = L.layerGroup([satellite, hybrid_labels]);
var dark = L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png', {
maxZoom: 19, attribution: '© OpenStreetMap, © CARTO',
subdomains: 'abcd'
});
var light = L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png', {
maxZoom: 19, attribution: '© OpenStreetMap, © CARTO',
subdomains: 'abcd'
});
var baseMaps = {
'Streets (OSM)': streets,
'Satellite': satellite,
'Satellite + labels': satelliteHybrid,
'Topographic': topo,
'Light': light,
'Dark': dark
};
// Restore saved basemap or fall back to Streets
var savedBase = localStorage.getItem('aprsMapBasemap') || 'Streets (OSM)';
var initialLayer = baseMaps[savedBase] || streets;
initialLayer.addTo(map);
L.control.layers(baseMaps, null, {
position: 'topright', collapsed: true
}).addTo(map);
// Persist user choice on basemap switch
map.on('baselayerchange', function (e) {
localStorage.setItem('aprsMapBasemap', e.name);
});
document.getElementById('aprsRefreshBtn').addEventListener('click', loadStations);
document.getElementById('aprsSinceMin').addEventListener('change', loadStations);
// Phase 99h-v2 (2026-06-29) — single split layout, no more
// Map/List view toggle. Setup column header (JS-rendered),
// columns dropdown, split-pane drag handle, text filter.
renderListHead();
renderColsDropdown();
document.getElementById('aprsColsReset').addEventListener('click', function (e) {
e.preventDefault(); resetColsConfig();
});
initGridStack();
var filterEl = document.getElementById('aprsListFilter');
if (filterEl) filterEl.addEventListener('input', renderList);
// Type-filter dropdown (Phase 99h-v3 / Eric beta 2026-06-29)
renderTypeFilterDropdown();
document.getElementById('aprsTypeFilterAll').addEventListener('click', function (e) {
e.preventDefault();
hiddenTypes = {};
saveHiddenTypes();
renderTypeFilterDropdown();
renderStations();
renderList();
});
document.getElementById('aprsTypeFilterNone').addEventListener('click', function (e) {
e.preventDefault();
hiddenTypes = {};
_typesInData().forEach(function (t) { hiddenTypes[t] = true; });
saveHiddenTypes();
renderTypeFilterDropdown();
renderStations();
renderList();
});
// Motion projection — toggle UI + dead-reckon interval.
bindMotionToggle();
// Watchlist layer — toggle now FILTERS to watched-only
// (Eric beta 2026-06-29). Preference persisted in localStorage.
bindWatchLayerToggle();
loadWatchlist();
loadStations();
refreshTimer = setInterval(loadStations, 60000); // 60s auto-refresh
// Refresh watchlist every 30s so changes by other admins
// become visible without a hard page refresh.
setInterval(loadWatchlist, 30000);
}
// ─── Watchlist load / toggle / visibility ─────────────────────
function loadWatchlist() {
return fetch('api/aprs-watchlist.php', { credentials: 'same-origin' })
.then(function (r) { return r.json(); })
.then(function (data) {
if (data.error) return;
watchSet = new Set();
(data.watchlist || []).forEach(function (w) {
watchSet.add((w.callsign || '').toUpperCase());
});
watchCanWrite = !!data.can_write;
var countEl = document.getElementById('aprsWatchCount');
if (countEl) countEl.textContent = watchSet.size;
// Single-view layout: always re-render both panes.
renderStations();
renderList();
})
.catch(function () { /* non-fatal */ });
}
function toggleWatch(callsign) {
if (!watchCanWrite) return;
var cs = String(callsign).toUpperCase();
var action = watchSet.has(cs) ? 'remove' : 'add';
fetch('api/aprs-watchlist.php', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
},
body: JSON.stringify({ action: action, callsign: cs })
})
.then(function (r) { return r.json(); })
.then(function (data) {
if (data.error) {
alert('Watchlist update failed: ' + data.error);
return;
}
if (action === 'add') watchSet.add(cs); else watchSet.delete(cs);
var countEl = document.getElementById('aprsWatchCount');
if (countEl) countEl.textContent = watchSet.size;
renderStations();
renderList();
})
.catch(function (err) { alert('Network error: ' + err.message); });
}
function bindWatchLayerToggle() {
var toggle = document.getElementById('aprsWatchLayerToggle');
if (!toggle) return;
// 2026-06-29 v2: now defaults OFF (no filter). Toggle ON = filter
// to watched-only stations. Switches role from cosmetic
// highlight to actual content filter per Eric's clarification:
// "when it's toggled on, only stations that are on the watch
// list appear... That would declutter and make it easier to
// see what you are about to expose to the map layer".
var saved = localStorage.getItem('aprsWatchLayerVisible');
toggle.checked = (saved === '1');
toggle.addEventListener('change', function () {
localStorage.setItem('aprsWatchLayerVisible', this.checked ? '1' : '0');
renderStations();
renderList();
});
}
function watchLayerVisible() {
// True when the "Watched only" filter is active.
var t = document.getElementById('aprsWatchLayerToggle');
return !!(t && t.checked);
}
// Sort state — default: freshest first
var listSort = { key: 'age_sec', dir: 'asc' };
// Row drill-in / pop-back state (Phase 99h-v2 fix, 2026-06-29).
// activeRowCallsign = callsign currently zoomed-into via row click.
// prevMapView = the {center, zoom} the map was at BEFORE the
// first drill-in, so the toggle-back can restore it. Clearing
// activeRowCallsign clears prevMapView too.
var activeRowCallsign = null;
var prevMapView = null;
function loadStations() {
var since = document.getElementById('aprsSinceMin').value || '60';
fetch('api/aprs-positions.php?since_min=' + encodeURIComponent(since) + '&limit=500',
{ credentials: 'same-origin' })
.then(function (r) { return r.json(); })
.then(function (data) {
if (data.error) {
// Phase 99h-v2 fix (2026-06-29): the old branch
// wrote into #aprsStationList, which the split-
// layout refactor removed. Null-deref threw,
// bubbled into .catch, badge stuck red.
document.getElementById('aprsStatusBadge').className = 'badge bg-danger';
document.getElementById('aprsStatusBadge').textContent = 'Error';
var tb = document.getElementById('aprsListBody');
if (tb) tb.innerHTML = '<tr><td class="text-center text-danger py-3" colspan="20">' + escapeHtml(data.error) + '</td></tr>';
return;
}
stations = data.stations || [];
updateStatus(data);
renderTypeFilterDropdown(); // refresh checkbox list + counts
renderStations();
renderList();
})
.catch(function (err) {
document.getElementById('aprsStatusBadge').className = 'badge bg-danger';
document.getElementById('aprsStatusBadge').textContent = 'Network error';
});
}
function updateStatus(data) {
var badge = document.getElementById('aprsStatusBadge');
var warn = document.getElementById('aprsListenerWarning');
var detail = document.getElementById('aprsListenerDetail');
var count = document.getElementById('aprsStationCount');
// 2026-06-29 fix: prefer unique_stations_in_window (true
// distinct count) over data.count (capped by LIMIT).
var trueCount = (data.unique_stations_in_window != null)
? data.unique_stations_in_window
: data.count;
count.textContent = trueCount + ' station' + (trueCount === 1 ? '' : 's') + ' in window';
if (data.listener_status === 'running') {
badge.className = 'badge bg-success';
badge.innerHTML = '<i class="bi bi-circle-fill me-1" style="font-size:0.5rem;vertical-align:middle;"></i>Listener active';
warn.classList.add('d-none');
} else if (data.listener_status === 'stopped') {
badge.className = 'badge bg-warning text-dark';
badge.textContent = 'Listener stale';
warn.classList.remove('d-none');
detail.textContent = 'Last position received ' + formatAge(data.provider.last_seen_ago_sec) + ' ago.';
} else {
badge.className = 'badge bg-secondary';
badge.textContent = 'Not configured';
warn.classList.remove('d-none');
detail.textContent = 'No APRS-IS position rows ever received.';
}
}
function renderStations() {
// Clear old markers. Phase 99h-v2 fix (2026-06-29): also
// walk map.eachLayer to catch orphaned L.Marker instances
// that escaped the `markers` dict — Eric reported 4 stations
// persisting on the map after toggling the Watched-only filter
// on with an empty watchlist. Hypothesis: a race between
// loadWatchlist's .then() and loadStations' .then() (both call
// renderStations) could overwrite markers[callsign] before the
// earlier marker was removed. Belt-and-suspenders cleanup
// below removes any L.Marker on the map regardless of tracking.
Object.keys(markers).forEach(function (k) { map.removeLayer(markers[k]); });
markers = {};
map.eachLayer(function (layer) {
if (layer instanceof L.Marker) {
map.removeLayer(layer);
}
});
// Phase 99h-v2 (2026-06-29) — sidebar removed; the table
// below the map IS the station list now. Map markers
// honor the Watched-only filter (toggle behavior changed
// from cosmetic-highlight to actual-filter per Eric).
var watchedOnly = watchLayerVisible();
var visibleStations = stations.filter(_typeAllowed);
if (watchedOnly) {
visibleStations = visibleStations.filter(function (s) {
return watchSet.has(String(s.callsign).toUpperCase());
});
}
visibleStations.forEach(function (s) {
var ageClass = (s.age_sec < 300) ? 'aprs-age-fresh'
: (s.age_sec < 1800) ? 'aprs-age-stale'
: 'aprs-age-cold';
var color = (s.age_sec < 300) ? '#198754'
: (s.age_sec < 1800) ? '#ffc107'
: '#6c757d';
// Watched stations always get gold styling, regardless
// of whether the filter is on. Visual signal stays
// consistent (gold = watched).
var isWatched = watchSet.has(String(s.callsign).toUpperCase());
var icon = _aprsBuildIcon(s.symbol, color, isWatched);
// Phase 99h-v2 fix (2026-06-29): start the marker at the
// EXTRAPOLATED-NOW position, not the raw last-reported
// position. Eric beta found high-speed stations (e.g.
// N0OQA at 20.6 kph reported 11 min ago) would render at
// base, then on the first motionTick teleport ~4 km away
// — instantly leaving a zoomed-in viewport. Computing the
// projected-current position at render time means no
// first-tick teleport: the marker is already where it
// should be, motionTick only adds small per-second deltas.
// baseLat/baseLng in _aprsMotion stay as the LAST-REPORTED
// fix so motionTick math stays absolute (no per-tick
// accumulation drift) and stopMotionLoop can still snap
// back to the real last-known position.
var speedKph = parseFloat(s.speed) || 0;
var heading = parseFloat(s.heading) || 0;
var initLat = s.lat, initLng = s.lng;
if (motionEnabled() && speedKph > 0 && _shouldExtrapolate(s)) {
var p = _projectPos(s.lat, s.lng, speedKph, heading, Math.min(s.age_sec, APRS_MOTION_TIMEOUT_MIN * 60));
initLat = p.lat; initLng = p.lng;
}
var marker = L.marker([initLat, initLng], { icon: icon }).addTo(map);
// Phase 99h-v5 fix (2026-06-29): popup units match the
// table (mph + ft). Eric beta: "can we have the balloon
// that bubbles up when you click on an object also
// respect the units setting? This example displays in
// KM instead of Miles". The listener stores SI (kph + m);
// we convert at display.
var popupSpeed = '';
if (s.speed > 0) {
var mphVal = (s.speed * 0.621371);
popupSpeed = '<div class="small">Speed: ' + mphVal.toFixed(1) +
' mph · Heading: ' + Math.round(s.heading) + '°</div>';
}
var popupAlt = '';
if (s.altitude != null && s.altitude > 0) {
popupAlt = '<div class="small">Alt: ' + Math.round(s.altitude * 3.28084) + ' ft</div>';
}
marker.bindPopup(
'<div><strong>' + escapeHtml(s.callsign) + '</strong></div>' +
'<div class="small text-body-secondary">' + s.lat.toFixed(5) + ', ' + s.lng.toFixed(5) + '</div>' +
popupSpeed + popupAlt +
'<div class="small ' + ageClass + '">' + formatAge(s.age_sec) + ' ago</div>' +
(isWatched ? '<div class="small text-warning"><i class="bi bi-star-fill"></i> Watched</div>' : '')
);
marker._aprsMotion = {
baseLat: s.lat,
baseLng: s.lng,
speedKph: speedKph,
heading: heading,
anchorMs: Date.now() - (s.age_sec * 1000),
isWeather: !_shouldExtrapolate(s) // suppress for weather symbols
};
markers[s.callsign] = marker;
});
// Auto-fit bounds to whatever's currently visible (first load only).
if (visibleStations.length > 0 && !map._aprsBoundsSet) {
var bounds = L.latLngBounds(visibleStations.map(function (s) { return [s.lat, s.lng]; }));
map.fitBounds(bounds, { padding: [20, 20], maxZoom: 11 });
map._aprsBoundsSet = true;
}
}
// ─── Motion projection (dead-reckon) ─────────────────────────
// Eric beta 2026-06-29: animate moving stations between
// server refreshes using last known speed + heading.
// All client-side; zero server load.
//
// Math: small-circle approximation, good for short-horizon
// extrapolation (<5 min at amateur-mobile speeds = <8km).
// deltaLat = (distKm / 6371) × cos(headingRad) × 180/π
// deltaLng = (distKm / 6371) × sin(headingRad) × 180/π / cos(latRad)
// Distance accumulates as (speed_kph × elapsed_sec / 3600).
//
// Stale-stop: any station whose last actual fix is older than
// APRS_MOTION_TIMEOUT_MIN gets its marker held at its current
// position (no further projection). Prevents runaway extrapolation
// when a unit has actually stopped.
// Phase 99h-v2 fixes (2026-06-29 — motion debugging):
// 1. setStyle({...}) was being called on L.Marker — but
// setStyle exists on path layers (Polygon, Circle), NOT
// on L.Marker with divIcon. It threw TypeError, breaking
// the forEach for that tick. Replaced with divIcon opacity
// via mk.getElement().style.opacity.
// 2. 5-min timeout was too aggressive. Most APRS stations
// beacon every 10-15 min when stationary, so 5 min cut
// off motion projection for everything Eric was looking
// at. Bumped to 15 min.
// 3. When projection stops (past timeout), freeze the marker
// at its LAST extrapolated position — don't snap it back
// to base. The user watched it drift; popping back to base
// is jarring and undoes the projection they were tracking.
// User-configurable timeout (Phase 99h-v5, 2026-06-29).
// Eric beta: "could that be exposed on the page so I could
// predict the location of an object that maybe hasn't been
// received for 20 minutes? Or any other time period I want
// to input?". Range 1-240 min, default 15.
var APRS_MOTION_TIMEOUT_MIN = (function () {
var v = parseFloat(localStorage.getItem('aprsMotionTimeoutMin'));
return (v >= 1 && v <= 240) ? v : 15;
})();
function _setMotionTimeout(mins) {
var v = parseFloat(mins);
if (!(v >= 1 && v <= 240)) return;
APRS_MOTION_TIMEOUT_MIN = v;
localStorage.setItem('aprsMotionTimeoutMin', String(v));
// Re-render so markers reposition immediately to the new
// capped-extrapolation position (instead of waiting up to
// 1 second for the next motionTick).
renderStations();
}
var APRS_MOTION_TICK_MS = 1000;
var motionTimer = null;
// Phase 99h-v2 fix (2026-06-29): shared projection helper.
// Returns {lat,lng} at the position {baseLat,baseLng} would be
// after `elapsedSec` of travel at speedKph in `heading` degrees.
// Small-circle approximation, good for short-horizon extrapolation
// (<15 min at amateur-mobile speeds is <30km).
function _projectPos(baseLat, baseLng, speedKph, heading, elapsedSec) {
if (!(speedKph > 0)) return { lat: baseLat, lng: baseLng };
var earthR = 6371;
var distKm = (speedKph * elapsedSec) / 3600;
var bearingRad = heading * Math.PI / 180;
var latRad = baseLat * Math.PI / 180;
var deltaLat = (distKm / earthR) * Math.cos(bearingRad) * (180 / Math.PI);
var deltaLng = (distKm / earthR) * Math.sin(bearingRad) * (180 / Math.PI) / Math.cos(latRad);
return { lat: baseLat + deltaLat, lng: baseLng + deltaLng };
}
// Should this station's speed/heading be used as motion data?
// Eric beta 2026-06-29: APRS weather stations (symbol '_') encode
// wind direction + wind speed in the same packet field that
// mobile stations use for course/speed. aprslib parses both the
// same way, so the listener stores wind data as speed/heading.
//
// Three layers of defense (any one is enough):
// 1. Symbol char (`_` or `W`) — quick check, works for any
// packet with a parseable uncompressed-position symbol.
// 2. station_type from server — covers any future server-side
// classification additions (e.g. MIC-E weather packets).
// 3. Server-side speed/heading zeroing — already in place;
// motionTick's `speedKph <= 0` check is the last line.
var _STATIC_TYPES = {
'Weather': 1, 'WX site': 1, 'House': 1, 'Hospital': 1,
'EOC': 1, 'Repeater': 1, 'Igate': 1, 'TCP/IP': 1,
'Node': 1, 'Dish': 1, 'Yagi': 1, 'Red Cross': 1
};
// Per-user type filter (Phase 99h-v3 / Eric beta 2026-06-29).
// Deny-list: anything in hiddenTypes is hidden from table+map.
// New types appear by default (since they're absent from the set).
var hiddenTypes = (function () {
try {
return JSON.parse(localStorage.getItem('aprsHiddenTypes') || '{}') || {};
} catch (_) { return {}; }
})();
function saveHiddenTypes() {
localStorage.setItem('aprsHiddenTypes', JSON.stringify(hiddenTypes));
}
function _typesInData() {
var seen = {};
stations.forEach(function (s) {
var t = s.station_type || 'Other';
seen[t] = true;
});
return Object.keys(seen).sort();
}
function _typeAllowed(s) {
var t = s.station_type || 'Other';
return !hiddenTypes[t];
}
function renderTypeFilterDropdown() {
var list = document.getElementById('aprsTypeFilterList');
var label = document.getElementById('aprsTypeFilterLabel');
if (!list) return;
var types = _typesInData();
var hiddenCount = types.filter(function (t) { return hiddenTypes[t]; }).length;
if (label) {
if (types.length === 0) label.textContent = '(none)';
else if (hiddenCount === 0) label.textContent = '(all)';
else label.textContent = '(' + (types.length - hiddenCount) + '/' + types.length + ')';
}
if (types.length === 0) {
list.innerHTML = '<div class="px-3 py-2 small text-body-secondary">No data yet</div>';
return;
}
// Count stations per type for context
var counts = {};
stations.forEach(function (s) {
var t = s.station_type || 'Other';
counts[t] = (counts[t] || 0) + 1;
});
var html = '';
types.forEach(function (t) {
var checked = !hiddenTypes[t] ? 'checked' : '';
html += '<label class="dropdown-item small d-flex align-items-center gap-2" style="cursor:pointer;">' +
'<input type="checkbox" class="form-check-input aprs-type-cb m-0" ' + checked +
' data-type="' + escapeAttr(t) + '">' +
'<span class="flex-grow-1">' + escapeHtml(t) + '</span>' +
'<span class="badge bg-secondary" style="font-size:0.65rem;">' + counts[t] + '</span>' +
'</label>';
});
list.innerHTML = html;
list.querySelectorAll('.aprs-type-cb').forEach(function (cb) {
cb.addEventListener('change', function () {
var t = this.getAttribute('data-type');
if (this.checked) delete hiddenTypes[t];
else hiddenTypes[t] = true;
saveHiddenTypes();
renderTypeFilterDropdown(); // refresh label + counts
renderStations();
renderList();
});
});
}
function _shouldExtrapolate(s) {
if (s.station_type && _STATIC_TYPES[s.station_type]) return false;
if (!s.symbol) return true;
var c = s.symbol.charAt(1);
return c !== '_' && c !== 'W';
}
function motionEnabled() {
var toggle = document.getElementById('aprsMotionToggle');
return !!(toggle && toggle.checked);
}
function startMotionLoop() {
stopMotionLoop();
if (!motionEnabled()) return;
motionTimer = setInterval(motionTick, APRS_MOTION_TICK_MS);
}
function stopMotionLoop() {
if (motionTimer) { clearInterval(motionTimer); motionTimer = null; }
// Snap markers back to base + restore opacity when motion off.
Object.keys(markers).forEach(function (cs) {
var mk = markers[cs];
if (mk && mk._aprsMotion) {
mk.setLatLng([mk._aprsMotion.baseLat, mk._aprsMotion.baseLng]);
var el = mk.getElement();
if (el) el.style.opacity = '1';
mk._aprsMotion._dimmed = false;
}
});
}
function motionTick() {
var nowMs = Date.now();
var timeoutMs = APRS_MOTION_TIMEOUT_MIN * 60 * 1000;
Object.keys(markers).forEach(function (cs) {
var mk = markers[cs];
if (!mk || !mk._aprsMotion) return;
var m = mk._aprsMotion;
if (m.speedKph <= 0) return; // static — stay put
if (m.isWeather) return; // wind data, not motion
var elapsedMs = nowMs - m.anchorMs;
// Cap elapsed at timeout so the marker freezes at the
// last-projected position (no runaway, no snap-back).
var effElapsedSec = Math.min(elapsedMs, timeoutMs) / 1000;
var p = _projectPos(m.baseLat, m.baseLng, m.speedKph, m.heading, effElapsedSec);
mk.setLatLng([p.lat, p.lng]);
// Dim the icon once we've hit the timeout (one-time).
if (elapsedMs > timeoutMs && !m._dimmed) {
var el = mk.getElement();
if (el) el.style.opacity = '0.55';
m._dimmed = true;
}
});
}
function bindMotionToggle() {
var toggle = document.getElementById('aprsMotionToggle');
if (!toggle) return;
var saved = localStorage.getItem('aprsMotionEnabled');
toggle.checked = (saved === '1');
toggle.addEventListener('change', function () {
localStorage.setItem('aprsMotionEnabled', this.checked ? '1' : '0');
if (this.checked) startMotionLoop();
else stopMotionLoop();
});
if (toggle.checked) startMotionLoop();
// Motion-timeout input (Phase 99h-v5). Restore saved value
// + wire change-handler. Debounced to avoid re-rendering
// on every keystroke.
var timeoutInput = document.getElementById('aprsMotionTimeout');
if (timeoutInput) {
timeoutInput.value = APRS_MOTION_TIMEOUT_MIN;
var debounce;
timeoutInput.addEventListener('input', function () {
clearTimeout(debounce);
var val = this.value;
debounce = setTimeout(function () { _setMotionTimeout(val); }, 400);
});
}
}
// ─── APRS symbol → emoji glyph (Eric beta 2026-06-29) ─────────
// APRS standard symbol set: 2 chars (table + char). Table is
// '/' (primary) or '\' (alternate, usually emergency variants).
// Full set has ~190 symbols; we cover the ~30 most common in