-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathsituation.php
More file actions
2634 lines (2452 loc) · 126 KB
/
Copy pathsituation.php
File metadata and controls
2634 lines (2452 loc) · 126 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 - Full-Screen Situation View
*
* Opens in a new browser window (target="_blank" from navbar).
* Full-screen Leaflet map with semi-transparent overlay showing:
* - Active incident count, unit count
* - List of open incidents with type, address, severity color
* - Time-range dropdown (Current, Closed Today/Week/Month/Year)
* Severity-colored map markers, click-to-zoom, SSE auto-refresh.
*/
require_once __DIR__ . '/config.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/rbac.php';
rbac_require_screen('screen.situation');
require_once __DIR__ . '/inc/force-pw-change.php';
force_pw_change_redirect();
// Phase 99o (Eric beta 2026-06-29) — admin-configured label
// ("Incident" / "Case" / "Call" / ...) so the situation list uses
// the same vocabulary as the dashboard widget and detail page.
require_once __DIR__ . '/inc/incident-number.php';
$incNumLabel = incnum_get_label();
$user = e($_SESSION['user']);
$level = current_role_name();
$theme = $_SESSION['day_night'] ?? 'Day';
$bs_theme = ($theme === 'Night') ? 'dark' : 'light';
$csrf = csrf_token();
$active_page = 'situation';
// Configurable auto-refresh cadences (seconds), read server-side so the JS
// has them synchronously. Defaults preserve prior behaviour — unit tracking
// every 10s, incident/board refresh every 15s. Admins can retune per install
// (a busy EOC may want faster polling; a slow uplink, slower). Floored so a
// bad value can't hammer the server.
$sitUnitRefreshSecs = max(3, (int) get_setting('situation_unit_refresh_secs', 10));
$sitBoardRefreshSecs = max(5, (int) get_setting('situation_board_refresh_secs', 15));
// GH #58 (Eric 2026-07-05) — when the operator has LOCKED the situation view
// (zoomed/panned) and a NEW incident is created outside the current map extents,
// automatically unlock + re-fit so the new event is visible. Admin-configurable
// at settings.php#map-defaults (stored in the settings table via that form);
// default ON so a new incident is never missed. get_variable() returns false
// when unset → treat as enabled.
$sitResetOffscreenRaw = get_variable('situation_reset_on_offscreen');
$sitResetOffscreen = ($sitResetOffscreenRaw === false || $sitResetOffscreenRaw === '')
? 1 : ((int) $sitResetOffscreenRaw ? 1 : 0);
?>
<!DOCTYPE html>
<html lang="en" 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 e($csrf); ?>">
<title>EOC Display — Tickets CAD <?php echo newui_version(); ?></title>
<!-- Vendor CSS -->
<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">
<!-- App CSS -->
<link rel="stylesheet" href="assets/css/dashboard.css">
<link rel="stylesheet" href="assets/css/unit-tracking.css">
<link rel="stylesheet" href="assets/css/mobile.css">
<link rel="stylesheet" href="assets/css/print.css" media="print">
<style>
/* Full-screen layout: map fills everything below the navbar */
html, body { height: 100%; margin: 0; }
body { display: flex; flex-direction: column; overflow: hidden; }
/* a beta tester beta 2026-06-29: navbar uses Bootstrap `sticky-top`
(position: sticky) globally — but in a flex column with
overflow:hidden, sticky doesn't reserve flex space in some
browsers, so #sitContainer renders starting at y=0 and the
overlay slides under the navbar. There's no scroll on this
page anyway, so static positioning is fine and makes the
flex column do the right thing: navbar takes its natural
height, sitContainer fills the rest. */
#appHeader { flex-shrink: 0; position: static; }
#sitContainer { position: relative; flex: 1; overflow: hidden; min-height: 50vh; }
/* Phase 68 — `dvh` lets the container track the visible viewport
when iOS Safari / Android Chrome collapse the address bar.
Falls back gracefully when dvh isn't supported. */
@supports (height: 100dvh) {
#sitContainer { min-height: 50dvh; }
}
#sitMap { position: absolute; top: 0; left: 0; right: 0; bottom: 0; z-index: 1; }
/* Semi-transparent overlay panel */
#sitOverlay {
position: absolute; top: 10px; left: 10px; z-index: 1000;
width: 480px; max-height: calc(100% - 20px); overflow-y: auto;
background: rgba(var(--bs-body-bg-rgb), 0.88);
border-radius: 8px; padding: 10px 12px;
backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
box-shadow: 0 4px 20px rgba(0,0,0,0.35);
}
#sitOverlay::-webkit-scrollbar { width: 8px; }
#sitOverlay::-webkit-scrollbar-thumb { background: var(--bs-border-color); border-radius: 4px; }
#sitOverlay::-webkit-scrollbar-thumb:hover { background: var(--bs-secondary-color); }
/* GH#47 follow-up (cbyrdmo, 2026-08-15) — with enough active
incidents/units the list overflows #sitOverlay's height (it's
capped at calc(100% - 20px) of the viewport) and the thin
scrollbar was easy to miss, so a real dispatcher's day-to-day
dataset (confirmed reproducible with 26 open incidents / 18
units on a plain 1366x768 desktop, no exotic viewport needed)
looked like the bottom of the list had simply been cut off the
screen rather than needing a scroll. This fade sits pinned to
the scroll container's own bottom edge (position: sticky
inside #sitOverlay, which is itself the overflow-y:auto
container) so it's always exactly where the visible content
actually ends, in any tab (incidents/units/facilities/events
are siblings — being the last DOM child means it trails
whichever one is currently displayed). Toggled by JS based on
actual scroll position so it disappears once you've reached
the true end of the list. */
#sitOverlayFade {
position: sticky; bottom: -1px; left: 0; right: 0;
height: 28px; margin-top: -28px; z-index: 2;
background: linear-gradient(to bottom, transparent, rgba(var(--bs-body-bg-rgb), 0.92) 70%);
pointer-events: none;
opacity: 0; transition: opacity 0.15s ease;
}
#sitOverlay.has-more-below #sitOverlayFade { opacity: 1; }
/* Summary bar */
.sit-summary { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.sit-summary .badge { font-size: 0.72rem; }
/* Incident table */
.sit-table { font-size: 0.73rem; margin-bottom: 0; }
.sit-table th { font-size: 0.63rem; text-transform: uppercase; letter-spacing: 0.03em; white-space: nowrap; }
.sit-table td { padding: 3px 6px; vertical-align: middle; }
.sit-row { cursor: pointer; transition: background 0.15s; }
.sit-row:hover { background: rgba(var(--bs-primary-rgb), 0.12) !important; }
.sit-row.active { background: rgba(var(--bs-primary-rgb), 0.2) !important; }
/* Severity dot */
.sev-dot {
display: inline-block; width: 10px; height: 10px;
border-radius: 50%; border: 1px solid rgba(0,0,0,0.2);
}
/* Phase 109 Slice D — permanent zone-name labels on the map. Readable
at command-vehicle distance, no bubble chrome. */
.sit-zone-label {
background: transparent; border: none; box-shadow: none;
font-weight: 700; font-size: 0.85rem; color: #212529;
text-shadow: 0 0 3px #fff, 0 0 5px #fff;
}
.sit-zone-label::before { display: none; }
/* Collapse toggle icon */
.sit-toggle { cursor: pointer; user-select: none; }
.sit-toggle .bi { transition: transform 0.2s; }
.sit-toggle.collapsed .bi { transform: rotate(-90deg); }
/* Phase 70 — Mobile layout. Abandon the absolute-positioned
overlay-over-map design on phones: it tried to do too much
in too little vertical space and the map kept rendering
blank because its flex parent settled to 0 height while JS
ran. Switch to a plain vertical stack: navbar → fixed-
height map → incidents list that scrolls with the page.
Supervisors get the same data, just stacked instead of
layered. */
@media (max-width: 768px) {
html, body {
height: auto;
overflow: auto !important;
}
body { display: block; }
#sitContainer {
display: flex;
flex-direction: column;
position: static;
height: auto;
min-height: 0;
overflow: visible;
}
#sitMap {
position: relative;
width: 100%;
height: 50vh;
min-height: 280px;
z-index: 1;
}
@supports (height: 100dvh) {
#sitMap { height: 50dvh; }
}
#sitOverlay {
position: static;
width: 100%;
max-height: none;
border-radius: 0;
margin: 0;
padding: 10px 12px;
background: var(--bs-body-bg);
backdrop-filter: none;
-webkit-backdrop-filter: none;
box-shadow: none;
border-top: 1px solid var(--bs-border-color);
overflow-y: visible;
}
/* Mobile stacks the whole page and scrolls the document, not
#sitOverlay itself -- the bottom-of-list fade only means
something for the desktop internal-scroll layout above. */
#sitOverlayFade { display: none; }
/* Map control overlays would collide with the new stacked
layout — hide the optional draw/markups affordances on
narrow screens. They're available on the desktop UI. */
#drawToolbar, #markupsPanel { display: none !important; }
/* Incident table: wider hit targets and word-break on the
address column so a long street doesn't blow the layout. */
.sit-table { font-size: 0.85rem; }
.sit-table td, .sit-table th { padding: 6px 4px; }
.sit-row td:nth-child(4) { word-break: break-word; }
/* GH#47 -- the desktop top:55px offset (pushing Leaflet's
corner controls out from under the overlaid header) doesn't
apply here: #sitMap switches to `position: relative` above
with its own normal-flow height, so the map no longer sits
behind the header and the offset would just waste space on
an already-small mobile map. Leaflet's own stock rule is
`top: 0` (its controls get their spacing from their own
10px margin-top, not from this container) -- restore that
rather than introducing a different offset of our own. */
.leaflet-top { top: 0; }
}
/* 2026-06-11 — Stack map controls vertically along the right
edge so they don't overlap. Leaflet's layer control occupies
the top-right corner; the draw toolbar drops below it; the
markups panel sits to the left of the draw toolbar. */
/* GH#47 (cbyrdmo, 2026-08-14) -- z-index:1010 alone never actually
fixed the collision it was meant to: #appHeader is z-index:1030
(above), so the top-right layer control still sat visually and
click-wise BEHIND the header the entire time -- confirmed live
with a Playwright probe (clicking .leaflet-control-layers-toggle
hit a navbar <small> element, not the control). #sitMap is a
full-bleed absolutely-positioned map (top:0) with the header
floating on top of it, so this was never a stacking-order
problem a bigger z-index could win on its own -- the control
needed to be physically moved out from under the header, not
raised above it (raising it above the header would render its
icon floating over navbar content instead). top:55px (the
header's own height) pushes it below the header; Leaflet's own
10px default control margin then supplies the usual gap. (The
zoom control had the same header collision but is fixed
separately in initMap() by moving it to bottomleft entirely --
see that comment for why a top-side offset wasn't enough there.) */
.leaflet-top { top: 55px; }
.leaflet-top.leaflet-right { z-index: 1010; }
#drawToolbar {
position: absolute; top: 200px; right: 10px; z-index: 1000;
display: flex; flex-direction: column; gap: 4px;
}
.draw-btn {
width: 34px; height: 34px;
border: 2px solid rgba(0,0,0,0.25);
border-radius: 4px;
background: var(--bs-body-bg, #fff);
color: var(--bs-body-color, #333);
font-size: 0.85rem;
cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: background 0.15s, border-color 0.15s;
}
.draw-btn:hover { background: var(--bs-secondary-bg); }
.draw-btn.active { border-color: var(--bs-primary); background: rgba(var(--bs-primary-rgb),0.15); }
/* Markups toggle panel */
#markupsPanel {
position: absolute; top: 200px; right: 55px; z-index: 1000;
background: rgba(var(--bs-body-bg-rgb), 0.92);
border-radius: 6px; padding: 8px;
backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
display: none; max-height: 300px; overflow-y: auto;
min-width: 180px; font-size: 0.75rem;
}
#markupsPanel.show { display: block; }
.markup-item { display: flex; align-items: center; gap: 6px; padding: 3px 0; }
.markup-item label { cursor: pointer; margin: 0; }
.markup-swatch {
width: 12px; height: 12px; border-radius: 2px;
border: 1px solid rgba(0,0,0,0.2); flex-shrink: 0;
}
</style>
</head>
<body>
<?php include_once NEWUI_ROOT . '/inc/navbar.php'; ?>
</header>
<div id="sitContainer">
<div id="sitMap"></div>
<div id="sitOverlay">
<!-- Header row -->
<div class="d-flex align-items-center justify-content-between mb-2">
<h6 class="mb-0 fw-bold" id="sitTitle">
<i class="bi bi-display me-1"></i>Situation
</h6>
<div class="d-flex align-items-center gap-2">
<select class="form-select form-select-sm" id="sitTimeRange" style="width:auto;font-size:0.72rem;"
title="Choose which incidents to show. 'Current' keeps recently-closed incidents on screen for the duration set below."
aria-label="Choose which incidents to show">
<option value="0">Current</option>
<option value="1">Closed Today</option>
<option value="5">Closed This Week</option>
<option value="7">Closed This Month</option>
<option value="9">Closed This Year</option>
</select>
<!-- 2026-06-11 — User-tunable recent-closed window for the
'Current' view. Closed incidents stay clickable for
this many minutes after closure. Saved per-user
via screen-prefs ('situation' screen). -->
<div class="input-group input-group-sm" id="sitRecentCloseWrap" style="width:auto;font-size:0.72rem;">
<span class="input-group-text py-0 px-1" style="font-size:0.7rem;" title="How long recently-closed incidents stay visible">Keep closed</span>
<input type="number" min="0" max="10080" step="15" class="form-control form-control-sm py-0 px-1"
id="sitRecentCloseMins" style="width:60px;font-size:0.7rem;" value="30" aria-label="Minutes to keep recently-closed incidents visible">
<span class="input-group-text py-0 px-1" style="font-size:0.7rem;">min</span>
</div>
<button class="btn btn-sm btn-outline-secondary py-0 px-1" id="sitCollapse" title="Toggle panel">
<i class="bi bi-chevron-down"></i>
</button>
</div>
</div>
<!-- Summary counts -->
<div class="sit-summary mb-2" id="sitSummary">
<span class="badge bg-primary"><i class="bi bi-exclamation-triangle me-1"></i>Incidents: <span id="cntIncidents">0</span></span>
<span class="badge bg-info"><i class="bi bi-people me-1"></i>Units: <span id="cntUnits">0</span></span>
<span class="badge bg-success" id="badgeSev0" title="Normal severity">Normal: <span id="cntSev0">0</span></span>
<span class="badge bg-warning text-dark" id="badgeSev1" title="Medium severity">Medium: <span id="cntSev1">0</span></span>
<span class="badge bg-danger" id="badgeSev2" title="High severity">High: <span id="cntSev2">0</span></span>
</div>
<!-- Phase 107 (issue #23): tab strip to switch the panel body
between Incidents / Units / Facilities. All three share
the same map real-estate; each renders its own list.
Tab selection persists per-user via localStorage. -->
<ul class="nav nav-tabs nav-sm mb-2" id="sitTabs" style="font-size:0.72rem;">
<li class="nav-item"><a class="nav-link active py-1 px-2" href="#" data-sittab="incidents"><i class="bi bi-exclamation-triangle me-1"></i>Incidents</a></li>
<li class="nav-item"><a class="nav-link py-1 px-2" href="#" data-sittab="units"><i class="bi bi-truck me-1"></i>Units</a></li>
<li class="nav-item"><a class="nav-link py-1 px-2" href="#" data-sittab="facilities"><i class="bi bi-building me-1"></i>Facilities</a></li>
<li class="nav-item"><a class="nav-link py-1 px-2" href="#" data-sittab="events"><i class="bi bi-journal-text me-1"></i>Events</a></li>
<!-- Eric 2026-07-07 (#67): Major moved from the navbar into
incident context -->
<li class="nav-item ms-auto"><a class="nav-link py-1 px-2" href="major-incidents.php"
title="Major incidents — link incidents under a command structure"><i class="bi bi-diagram-3 me-1"></i>Major</a></li>
</ul>
<!-- Incident list -->
<div id="sitBody" data-sittab-body="incidents">
<!-- GH #63 (a beta tester) — per-user column customization, same
ScreenPrefs infrastructure as units.php/facilities.php. -->
<div class="d-flex justify-content-end">
<button type="button" class="btn btn-sm btn-outline-secondary py-0 px-1 mb-1" id="btnSitIncidentCols"
title="Customize columns" style="font-size:0.7rem;">
<i class="bi bi-layout-three-columns"></i>
</button>
</div>
<table class="table table-sm table-hover sit-table" id="sitIncidentsTable">
<thead>
<tr>
<th data-col-id="sev" data-col-label="Sev">Sev</th>
<!-- Phase 99o (Eric beta 2026-06-29) — show the
admin-configured case number, not the
internal id (which dispatchers ignore). -->
<th data-col-id="case" data-col-label="<?php echo e($incNumLabel); ?> #"><?php echo e($incNumLabel); ?> #</th>
<th data-col-id="scope" data-col-label="Scope">Scope</th>
<th data-col-id="type" data-col-label="Type">Type</th>
<th data-col-id="address" data-col-label="Address">Address</th>
<th data-col-id="units" data-col-label="Units">Units</th>
<th data-col-id="updated" data-col-label="Updated">Updated</th>
</tr>
</thead>
<tbody id="sitIncidentList">
<tr><td colspan="7" class="text-center text-body-secondary py-3">Loading...</td></tr>
</tbody>
</table>
</div>
<!-- Phase 107 — Units list. Hidden until the tab is active. -->
<div id="sitUnitsBody" data-sittab-body="units" style="display:none;">
<!-- GH #63 (a beta tester) — per-user column customization. -->
<div class="d-flex justify-content-end">
<button type="button" class="btn btn-sm btn-outline-secondary py-0 px-1 mb-1" id="btnSitUnitCols"
title="Customize columns" style="font-size:0.7rem;">
<i class="bi bi-layout-three-columns"></i>
</button>
</div>
<table class="table table-sm table-hover sit-table" id="sitUnitsTable">
<thead>
<tr>
<th data-col-id="dot" data-col-label="Status Dot"> </th>
<th data-col-id="unit" data-col-label="Unit">Unit</th>
<th data-col-id="callsign" data-col-label="Callsign">Callsign</th>
<th data-col-id="principal" data-col-label="Principal">Principal</th>
<th data-col-id="status" data-col-label="Status">Status</th>
<th data-col-id="location" data-col-label="Location">Location</th>
<th data-col-id="updated" data-col-label="Updated">Updated</th>
</tr>
</thead>
<tbody id="sitUnitsList">
<tr><td colspan="7" class="text-center text-body-secondary py-3">Loading...</td></tr>
</tbody>
</table>
</div>
<!-- Phase 107 — Facilities list. Hidden until the tab is active. -->
<div id="sitFacilitiesBody" data-sittab-body="facilities" style="display:none;">
<table class="table table-sm table-hover sit-table">
<thead>
<tr>
<th> </th>
<th>Name</th>
<th>Type</th>
<th>Status</th>
<th>Address</th>
</tr>
</thead>
<tbody id="sitFacilitiesList">
<tr><td colspan="5" class="text-center text-body-secondary py-3">Loading...</td></tr>
</tbody>
</table>
</div>
<!-- GH #78 — recent events feed incl. unit + facility notes -->
<div id="sitEventsBody" data-sittab-body="events" style="display:none;">
<table class="table table-sm table-hover sit-table" id="sitEventsTable">
<thead>
<tr>
<th>Time</th>
<th>Type</th>
<th>By</th>
<th>Detail</th>
</tr>
</thead>
<tbody id="sitEventsList">
<tr><td colspan="4" class="text-center text-body-secondary py-3">Loading...</td></tr>
</tbody>
</table>
</div>
<!-- GH#47 follow-up -- see the #sitOverlayFade CSS comment above.
Always the last child so it trails whichever tab's body is
currently visible (they're siblings toggled via display). -->
<div id="sitOverlayFade"></div>
</div>
<!-- Draw Toolbar -->
<div id="drawToolbar">
<button class="draw-btn" id="drawMarker" title="Place marker"><i class="bi bi-geo-alt"></i></button>
<button class="draw-btn" id="drawCircle" title="Draw circle"><i class="bi bi-circle"></i></button>
<button class="draw-btn" id="drawPolyline" title="Draw line"><i class="bi bi-pencil"></i></button>
<button class="draw-btn" id="drawPolygon" title="Draw polygon"><i class="bi bi-pentagon"></i></button>
<button class="draw-btn" id="drawFinish" title="Finish polygon/line" style="display:none;background:var(--bs-success);color:#fff;"><i class="bi bi-check-lg"></i></button>
<button class="draw-btn" id="drawCancel" title="Cancel drawing" style="display:none;"><i class="bi bi-x-lg text-danger"></i></button>
<hr style="margin:2px 0;border-color:var(--bs-border-color);">
<button class="draw-btn" id="toggleMarkups" title="Toggle saved markups"><i class="bi bi-layers"></i></button>
</div>
<!-- Markups Toggle Panel -->
<div id="markupsPanel">
<div class="fw-semibold mb-1"><i class="bi bi-layers me-1"></i>Saved Markups</div>
<div id="markupsList"><small class="text-body-secondary">Loading...</small></div>
</div>
</div>
<!-- Vendor JS -->
<script src="assets/vendor/bootstrap/bootstrap.bundle.min.js"></script>
<script src="assets/vendor/leaflet/leaflet.js"></script>
<script src="assets/js/leaflet-mobile-fit.js?v=<?php echo function_exists("asset_v")?asset_v("assets/js/leaflet-mobile-fit.js"):newui_version(); ?>"></script>
<script src="assets/js/leaflet-quadkey.js"></script>
<script src="assets/js/map-prefs.js?v=<?php echo asset_v('assets/js/map-prefs.js'); ?>"></script>
<script src="assets/js/map-image-overlays.js?v=<?php echo function_exists('asset_v') ? asset_v('assets/js/map-image-overlays.js') : newui_version(); ?>"></script>
<script src="assets/js/screen-prefs.js?v=<?php echo newui_version(); ?>"></script>
<script src="assets/js/unit-tracking.js"></script>
<script src="assets/js/event-bus.js"></script>
<script src="assets/js/facility-status.js?v=<?php echo function_exists('asset_v') ? asset_v('assets/js/facility-status.js') : newui_version(); ?>"></script>
<script>
(function () {
'use strict';
// ── State ──
var map, tileLayer, markerGroup;
// Shared with ensureUnitLayer()/ensureFacilityLayer() below — must be
// declared here, not with `var` inside initMap(), or those functions
// throw "sitLayersControl is not defined" the first time they run (GH
// #47): a ReferenceError that loadUnits()/loadFacilities() swallow
// silently via their fetch chain's empty .catch(function () {}), so the
// Units/Facilities overlay checkboxes never register and no error ever
// reaches the console.
var sitLayersControl;
var roadConditionsGroup = null;
var roadConditionsLoaded = false;
var incidents = [];
var unitCount = 0;
var sevColors = {};
var defaultLat = 39.8283;
var defaultLng = -98.5795;
var defaultZoom = 5;
var refreshTimer = null;
var panelCollapsed = false;
// GH #58 — reset a locked view when a NEW incident lands off-screen.
var SIT_RESET_ON_OFFSCREEN = <?php echo (int) $sitResetOffscreen; ?>;
var _seenIncidentIds = null; // null until the first incident load
// ── Map Initialization ──
var osmLight, cartoDark, topoMap; // basemap references for theme switching
// Eric 2026-07-03 (EOC event) — track whether the user has taken
// manual control of the view. Once they do, refresh cycles must
// never re-zoom or re-center; they can hit the ★ button to opt
// back into auto-fit. _initialFitDone flips true after the very
// first successful fitBounds so subsequent ticks don't re-fit
// even if _userLockedView hasn't caught yet.
var _userLockedView = false;
var _initialFitDone = false;
// Eric 2026-07-05 (#58) — mark our OWN fitBounds/setView so the user-view
// lock can tell them apart from a real user zoom/pan. The old lock keyed
// off Leaflet's `originalEvent`, which is ABSENT on the on-screen +/- zoom
// buttons — so zooming with those buttons never locked the view, and the
// next incident refresh snapped it back (the EOC "resets every few
// seconds" bug). Now any view change that ISN'T one of our programmatic
// fits locks the view.
var _programmaticView = false;
function _progFit(fn) {
_programmaticView = true;
try { fn(); } finally {
// Clear only after the resulting zoomend/moveend settle. fitBounds
// animates ~250ms; a bare setTimeout(0) can fire before moveend.
setTimeout(function () { _programmaticView = false; }, 450);
}
}
// Eric 2026-07-04 — relative auto-fit tightness. The map keeps
// fitting to include ALL active incidents, but this bias shifts how
// tight that fit is: + = zoom in closer (fill the screen with the
// half-mile park), − = zoom out looser (leave margin to watch an
// approaching storm on radar). Persisted per browser. Re-fit runs
// only when the incident SET changes (or the bias changes), so it
// no longer clobbers the view on every idle SSE tick.
var ZOOM_BIAS_KEY = 'newui_situation_zoom_bias';
var _zoomBias = 0;
try {
var _zb = parseInt(localStorage.getItem(ZOOM_BIAS_KEY), 10);
if (!isNaN(_zb)) _zoomBias = Math.max(-4, Math.min(4, _zb));
} catch (e) {}
var _lastFitSig = null;
function initMap() {
map = L.map('sitMap', {
// GH#47 (cbyrdmo, 2026-08-14) -- Leaflet's default zoomControl
// position is topleft. #sitOverlay (the incidents panel,
// position:absolute; left:10px; width:480px;
// max-height:calc(100% - 20px)) isn't just a top-left corner
// box -- with a full incidents list it spans nearly the ENTIRE
// left edge of the map top to bottom, so bottomleft collides
// with it exactly the same way topleft did. Confirmed live
// with a Playwright probe both times: real clicks kept landing
// on the overlay's own table rows, not the zoom button,
// wherever on the left edge the button was placed. The only
// corner the overlay's 480px width doesn't reach is the RIGHT
// side, where bottomright is otherwise empty (the attribution
// control there is a separate, independently-stacked Leaflet
// control in the same corner container -- they don't collide
// with each other the way absolutely-positioned page elements
// like #sitOverlay do).
zoomControl: false,
attributionControl: true
}).setView([defaultLat, defaultLng], defaultZoom);
L.control.zoom({ position: 'bottomright' }).addTo(map);
// GH #76 — auto-hide marker name labels when zoomed out + a toggle.
if (window.TypeIcons && window.TypeIcons.bindLabelZoom) { window.TypeIcons.bindLabelZoom(map); }
// Phase 68 — on mobile the container's height settles AFTER
// L.map() runs (address bar collapse, dvh kick-in, etc.). Without
// a follow-up invalidateSize the map renders into a 0-height
// canvas and stays blank. Multiple attempts cover slow phones
// and address-bar transitions.
setTimeout(function () { if (map) map.invalidateSize(); }, 100);
setTimeout(function () { if (map) map.invalidateSize(); }, 500);
setTimeout(function () { if (map) map.invalidateSize(); }, 1500);
// Expose for draw controls script
window._sitMap = map;
// Base layers
osmLight = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OSM', maxZoom: 19
});
cartoDark = L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution: '© CartoDB', maxZoom: 19
});
topoMap = L.tileLayer('https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png', {
attribution: '© OpenTopoMap', maxZoom: 17
});
// Set tileLayer reference and add preferred default basemap
var prefKey = window.MapPrefs ? window.MapPrefs.getBasemap() : (document.documentElement.getAttribute('data-bs-theme') === 'dark' ? 'dark' : 'street');
var prefMap = { street: osmLight, dark: cartoDark, terrain: topoMap };
tileLayer = prefMap[prefKey] || osmLight;
tileLayer.addTo(map);
var baseMaps = {
'Street Map': osmLight,
'Dark': cartoDark,
'Terrain': topoMap
};
// Weather overlays via caching proxy (fail gracefully if no API key)
var weatherOpts = { opacity: 0.5, maxZoom: 19, errorTileUrl: '' };
var weatherTemp = L.tileLayer('api/weather-proxy.php?type=tile&layer=temp&z={z}&x={x}&y={y}', weatherOpts);
var weatherPrecip = L.tileLayer('api/weather-proxy.php?type=tile&layer=precipitation_cls&z={z}&x={x}&y={y}', weatherOpts);
var weatherWind = L.tileLayer('api/weather-proxy.php?type=tile&layer=wind&z={z}&x={x}&y={y}', weatherOpts);
var weatherClouds = L.tileLayer('api/weather-proxy.php?type=tile&layer=clouds_cls&z={z}&x={x}&y={y}', weatherOpts);
// Road-conditions overlay (maps-comprehensive-2026-06) — toggleable
// layer plotting roadinfo reports with the condition icon + a popup.
roadConditionsGroup = L.layerGroup();
// Issue #53 (a beta tester 2026-07-03) — live precipitation RADAR for
// the EOC display. RainViewer: free, no API key, global mosaic.
// Their tile path embeds a frame timestamp, so we fetch the
// frame catalog and point the layer at the newest 'past' frame,
// then re-check every 5 minutes so a wall display stays current
// through a storm without a reload. Color scheme 4 = Universal
// Blue; trailing 1_1 = smoothed + snow shown.
// maxNativeZoom: 7 — RainViewer's radar mosaic only renders through
// zoom 7 (z8+ returns a "Zoom Level Not Supported" placeholder tile
// everywhere). Without this cap Leaflet keeps requesting radar tiles
// as you zoom in and paints those placeholders over the map. With it,
// Leaflet upscales the z7 tile instead — coarse but continuous, no
// error tiles. maxZoom stays 19 so the base map + markers still zoom
// in fully. (Eric, 2026-07-05 — #53 follow-up.)
var radarLayer = L.tileLayer('', { opacity: 0.7, maxZoom: 19, maxNativeZoom: 7, errorTileUrl: '' });
var radarTimer = null;
function refreshRadarFrame() {
fetch('https://api.rainviewer.com/public/weather-maps.json')
.then(function (r) { return r.json(); })
.then(function (cat) {
var frames = (cat && cat.radar && cat.radar.past) || [];
if (!frames.length) return;
var latest = frames[frames.length - 1];
var host = cat.host || 'https://tilecache.rainviewer.com';
radarLayer.setUrl(host + latest.path + '/256/{z}/{x}/{y}/4/1_1.png');
})
.catch(function () { /* offline / blocked — layer stays empty */ });
}
// Fetch the frame catalogue only while the radar layer is actually
// shown (docs/OFFLINE-OPERATION.md D7). It used to run on every load of
// this page and then every five minutes regardless — so a Situation
// wall display with radar switched OFF still contacted RainViewer
// around the clock. SECURITY.md said this happened only when radar was
// enabled; now that is true.
radarLayer.on('add', function () {
refreshRadarFrame();
if (!radarTimer) { radarTimer = setInterval(refreshRadarFrame, 5 * 60 * 1000); }
});
radarLayer.on('remove', function () {
if (radarTimer) { clearInterval(radarTimer); radarTimer = null; }
});
// NOAA/NWS MRMS base reflectivity (1 km CONUS, quality-controlled,
// event-driven ~2-min updates). Unlike RainViewer's cached global
// mosaic (native max zoom 7, coarse when zoomed in), this ArcGIS
// service renders DYNAMICALLY, so it stays sharp at ANY zoom — ideal
// for watching weather over a specific event site. WMS is disabled on
// the endpoint, so we use the ArcGIS REST `export` API via a tile
// layer that computes each tile's Web-Mercator (EPSG:3857) bbox.
// US-only coverage; no key. (Eric, 2026-07-05 — #53.)
var NOAA_MRMS_EXPORT = 'https://mapservices.weather.noaa.gov/eventdriven/rest/services/radar/radar_base_reflectivity/MapServer/export';
var MERC_HALF = 20037508.342789244; // half the Web-Mercator world extent (m)
var NoaaRadarLayer = L.TileLayer.extend({
getTileUrl: function (coords) {
var span = (2 * MERC_HALF) / Math.pow(2, coords.z);
var minX = -MERC_HALF + coords.x * span;
var maxX = minX + span;
var maxY = MERC_HALF - coords.y * span;
var minY = maxY - span;
return NOAA_MRMS_EXPORT
+ '?bbox=' + minX + ',' + minY + ',' + maxX + ',' + maxY
+ '&bboxSR=3857&imageSR=3857&size=256,256&format=png32'
+ '&transparent=true&layers=show:0&f=image&_ts=' + (this._noaaTs || 0);
}
});
var noaaRadarLayer = new NoaaRadarLayer('', { opacity: 0.75, maxZoom: 19, errorTileUrl: '' });
// Cache-bust + redraw on the MRMS cadence so a wall display stays live.
function refreshNoaaRadar() {
noaaRadarLayer._noaaTs = (new Date()).getTime();
if (map.hasLayer(noaaRadarLayer)) { noaaRadarLayer.redraw(); }
}
refreshNoaaRadar();
setInterval(refreshNoaaRadar, 150000); // ~2.5 min
var overlays = {
'Radar — US (NWS)': noaaRadarLayer,
'Radar — Global': radarLayer,
'Temperature': weatherTemp,
'Precipitation': weatherPrecip,
'Wind': weatherWind,
'Clouds': weatherClouds,
'● Road Conditions': roadConditionsGroup
};
sitLayersControl = L.control.layers(baseMaps, overlays, { collapsed: true, position: 'topright' }).addTo(map);
// ── Per-user layer visibility ──
// This screen previously persisted NOTHING: every overlay here was
// built, added and listed with no code anywhere reading or writing its
// state, so a dispatcher's choices lasted exactly until the next
// reload. Reconciles against the operator's saved choice (and the
// administrator default) using the synchronously-injected
// window.MAP_LAYER_PREFS, so there is no fetch and no visible flash.
// Units (EOC) / Facilities (EOC) register later, when their groups are
// built — see ensureUnitLayer() / ensureFacilityLayer().
if (window.MapLayerPrefs) {
window.MapLayerPrefs.bind(map, {
radar_us: noaaRadarLayer,
radar: radarLayer,
temperature: weatherTemp,
precipitation: weatherPrecip,
wind: weatherWind,
clouds: weatherClouds,
road_conditions: roadConditionsGroup
});
}
// GH #43 (Phase 110) — fold any configured event map image
// overlays into the layer control. Each enabled+positioned
// overlay becomes a toggleable layer sitting above the base
// tiles but below markups/units/incidents (own map pane).
if (window.MapImageOverlays && typeof window.MapImageOverlays.attach === 'function') {
window.MapImageOverlays.attach(map, sitLayersControl);
}
// ── Configured tile provider (specs/configurable-tile-providers-2026-06) ──
// Fold the admin-configured Tile Provider in as an additional base
// layer once map-prefs.js has fetched it. Additive + async; the
// built-in Street/Dark/Terrain options and default are unchanged.
if (window.MapPrefs && typeof window.MapPrefs.init === 'function') {
window.MapPrefs.init().then(function () {
var label = window.MapPrefs.getCustomLabel();
if (label && !sitLayersControl._ticketsCustomAdded) {
sitLayersControl.addBaseLayer(window.MapPrefs.makeLayer('custom'), label);
sitLayersControl._ticketsCustomAdded = true;
}
});
}
// #60/#46 (Eric 2026-07-05) — give the situation control the SAME
// per-category map-overlay toggles (race markers, zones, parade
// routes, ...) the dashboard and unit detail/edit maps already have,
// so an operator can turn individual overlays on/off here too. Shared
// logic in map-prefs.js; each category persists its own on/off state.
if (window.MapPrefs && typeof window.MapPrefs.addMarkupOverlays === 'function'
&& !sitLayersControl._ticketsMarkupAdded) {
window.MapPrefs.addMarkupOverlays(map, sitLayersControl);
sitLayersControl._ticketsMarkupAdded = true;
}
// ── Phase 109 Slice D — event-zone geometry overlay ──
// Draw the ACTIVE event's zones (points/polygons from event_zones.geo_json,
// set via the Net Control zones editor) so the big screen shades Zone 3
// etc. — the map IS the shared operating picture (decision #2). On by
// default; toggleable in the layer control. Zones without geometry are
// symbolic-only and simply don't render. Refreshes every 60s.
var eventZonesGroup = L.layerGroup().addTo(map);
sitLayersControl.addOverlay(eventZonesGroup,
'<span style="color:#6f42c1">●</span> Event Zones');
if (window.MapLayerPrefs) {
window.MapLayerPrefs.register(map, 'event_zones', eventZonesGroup);
}
function _addZoneLayer(z) {
var geom;
try { geom = JSON.parse(z.geo_json); } catch (e) { return; }
if (!geom) return;
var color = z.color || '#6f42c1';
try {
var layer = L.geoJSON(geom, {
style: { color: color, weight: 2, fillColor: color, fillOpacity: 0.15 },
pointToLayer: function (f, latlng) {
return L.circleMarker(latlng, {
radius: 9, color: color, weight: 2,
fillColor: color, fillOpacity: 0.5
});
}
});
layer.bindTooltip(z.name, { permanent: true, direction: 'center', className: 'sit-zone-label' });
eventZonesGroup.addLayer(layer);
} catch (e) { /* bad geometry — skip the zone, never break the map */ }
}
function loadEventZones() {
fetch('api/active-event.php', { credentials: 'same-origin' })
.then(function (r) { return r.json(); })
.then(function (ae) {
var tid = ae && ae.active_event_ticket_id ? parseInt(ae.active_event_ticket_id, 10) : 0;
if (!tid) { eventZonesGroup.clearLayers(); return null; }
return fetch('api/event-zones.php?ticket_id=' + tid, { credentials: 'same-origin' })
.then(function (r) { return r.json(); })
.then(function (data) {
eventZonesGroup.clearLayers();
var zones = (data && data.zones) || [];
for (var zi = 0; zi < zones.length; zi++) {
if (zones[zi].hide || !zones[zi].geo_json) continue;
_addZoneLayer(zones[zi]);
}
});
})
.catch(function () { /* offline / no perms — layer stays empty */ });
}
loadEventZones();
setInterval(loadEventZones, 60000);
// ── Phase 112 Phase 4 — active NWS alert polygons + who's inside ──
// Renders active warning polygons in severity colours; the popup names
// the units (and active-event zones) currently INSIDE each polygon,
// computed live by api/weather-alerts.php. Empty unless the install has
// weather alerts enabled — a weather-off install fetches once per
// minute and draws nothing.
var weatherAlertGroup = L.layerGroup().addTo(map);
sitLayersControl.addOverlay(weatherAlertGroup,
'<span style="color:#dc3545">▲</span> Weather Alerts');
if (window.MapLayerPrefs) {
window.MapLayerPrefs.register(map, 'weather_alerts', weatherAlertGroup);
}
var WX_SEV_COLOR = { 'Extreme': '#dc3545', 'Severe': '#fd7e14',
'Moderate': '#ffc107', 'Minor': '#6c757d' };
function _addWeatherAlertLayer(a) {
var geom;
try { geom = JSON.parse(a.polygon); } catch (e) { return; }
if (!geom) return;
var color = WX_SEV_COLOR[a.severity] || '#6c757d';
try {
var layer = L.geoJSON(geom, {
style: { color: color, weight: 2, dashArray: '6 4',
fillColor: color, fillOpacity: 0.12 }
});
var html = '<strong>' + esc(a.event || 'Weather alert') + '</strong>' +
'<br>' + esc(a.area_desc || '') +
(a.expires ? '<br>Expires: ' + esc(a.expires) : '');
var ui = a.units_inside || [];
if (ui.length) {
var names = [];
for (var n = 0; n < ui.length; n++) names.push(esc(ui[n].unit_identifier));
html += '<br><span class="text-danger fw-bold">Units inside: ' + names.join(', ') + '</span>';
}
var zi = a.zones_inside || [];
if (zi.length) {
var znames = [];
for (var zn = 0; zn < zi.length; zn++) znames.push(esc(zi[zn].name));
html += '<br><span class="fw-bold">Zones affected: ' + znames.join(', ') + '</span>';
}
layer.bindPopup(html);
weatherAlertGroup.addLayer(layer);
} catch (e) { /* bad polygon — skip, never break the map */ }
}
function loadWeatherAlertPolys() {
fetch('api/weather-alerts.php?action=active', { credentials: 'same-origin' })
.then(function (r) { return r.json(); })
.then(function (data) {
weatherAlertGroup.clearLayers();
if (!data || !data.enabled) return;
var alerts = data.alerts || [];
for (var wi = 0; wi < alerts.length; wi++) {
if (!alerts[wi].polygon) continue;
_addWeatherAlertLayer(alerts[wi]);
}
})
.catch(function () { /* offline — layer stays as-is */ });
}
loadWeatherAlertPolys();
setInterval(loadWeatherAlertPolys, 60000);
markerGroup = L.featureGroup().addTo(map);
// Populate the road-conditions layer once on map init.
loadRoadConditionsOverlay();
// ── User-view lock (Eric 2026-07-03, EOC storm event) ──
// Any zoom or pan the user initiates locks the view; refresh
// ticks stop touching zoom/center from here on out. We check
// the browser event (originalEvent) so that PROGRAMMATIC
// fitBounds/setView calls the code makes don't trigger the
// lock — only real user gestures do.
// Lock on ANY view change that isn't one of our programmatic fits —
// this catches the on-screen +/- zoom buttons (no originalEvent),
// mouse wheel, drag-pan, and pinch alike. Once locked, refresh ticks
// never touch zoom/center until the operator presses ★ (or a +/-
// tightness button) to opt back into auto-fit.
function _lockView() {
if (_programmaticView) return; // our own fit, not a user gesture
_userLockedView = true;
_initialFitDone = true;
// #59 P3 — the operator just panned/zoomed by hand; drop any
// row-focus restore point so a later click on that same row starts
// fresh instead of snapping back to a now-stale saved view.
_focusedKey = null; _preFocusView = null;
_refreshRecenterVisibility();
}
map.on('zoomend', _lockView);
map.on('moveend', _lockView);
// Persist the user's chosen weather / road-condition overlays
// across page loads (localStorage). Real EOCs run this view
// for hours; losing the storm layer on refresh is unusable.
var LAYER_PREF_KEY = 'newui_situation_overlays';
function _restoreOverlays() {
var saved;
try { saved = JSON.parse(localStorage.getItem(LAYER_PREF_KEY) || '[]'); }
catch (e) { saved = []; }
if (!saved.length) return;
var byName = overlays; // captured from the enclosing scope
for (var i = 0; i < saved.length; i++) {
var lyr = byName[saved[i]];
if (lyr && !map.hasLayer(lyr)) map.addLayer(lyr);
}
}
function _saveOverlays() {
var enabled = [];
for (var name in overlays) {
if (Object.prototype.hasOwnProperty.call(overlays, name)
&& map.hasLayer(overlays[name])) {
enabled.push(name);
}
}
try { localStorage.setItem(LAYER_PREF_KEY, JSON.stringify(enabled)); }
catch (e) {}
}
map.on('overlayadd', _saveOverlays);
map.on('overlayremove', _saveOverlays);
_restoreOverlays();
// ── Recenter control (top-right) — opts back into auto-fit. ──
// Hidden until the user has locked the view; shown when a
// dispatcher wants the map to jump back to the incident cloud.
var RecenterCtl = L.Control.extend({
options: { position: 'topright' },
onAdd: function () {
var div = L.DomUtil.create('div', 'leaflet-bar leaflet-control');
div.id = 'sitRecenterCtl';
div.style.display = 'none';
// GH #58 (Eric 2026-07-05) — a closed padlock reads as "the
// display is locked" far more intuitively than a star. Clicking
// it unlocks + re-fits to the incidents.
div.innerHTML = '<a href="#" title="Display locked — click to unlock and re-fit to incidents" '
+ 'style="width:30px;height:30px;line-height:30px;text-align:center;'
+ 'font-size:16px;color:#333;text-decoration:none;"><i class="bi bi-lock-fill"></i></a>';
L.DomEvent.disableClickPropagation(div);
L.DomEvent.on(div, 'click', function (e) {
L.DomEvent.preventDefault(e);
_userLockedView = false;
_initialFitDone = false;
refitCurrentIncidents(true);
_refreshRecenterVisibility();
});
return div;
}
});
map.addControl(new RecenterCtl());
// ── Auto-fit tightness control (Eric 2026-07-04) ──
// Separate from Leaflet's native +/− (which zooms the map
// directly). These bias the AUTO-FIT: how tightly the map hugs
// the active incidents. + fills the screen with the working
// area; − leaves margin to watch an approaching storm on radar.
// The bias persists and re-applies on every auto-fit, and each
// press also re-enables auto-fit (clears a manual lock).
function _applyBias(delta) {
_zoomBias = Math.max(-4, Math.min(4, _zoomBias + delta));
try { localStorage.setItem(ZOOM_BIAS_KEY, String(_zoomBias)); } catch (e) {}
_userLockedView = false; // this IS the user asking to auto-fit
refitCurrentIncidents(true);
_refreshRecenterVisibility();
}
var FitBiasCtl = L.Control.extend({
options: { position: 'topright' },
onAdd: function () {
var div = L.DomUtil.create('div', 'leaflet-bar leaflet-control');
div.innerHTML =
'<a href="#" id="sitFitTighter" title="Auto-fit tighter — zoom in closer on the active area" '
+ 'style="width:30px;height:30px;line-height:30px;text-align:center;font-size:16px;color:#333;text-decoration:none;">'
+ '<i class="bi bi-zoom-in"></i></a>'
+ '<a href="#" id="sitFitLooser" title="Auto-fit looser — zoom out to watch approaching weather" '
+ 'style="width:30px;height:30px;line-height:30px;text-align:center;font-size:16px;color:#333;text-decoration:none;">'