-
Notifications
You must be signed in to change notification settings - Fork 1
/
sartopo_bg.py
3148 lines (2930 loc) · 163 KB
/
sartopo_bg.py
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
import logging
import re
import time
import os
import sys
import json
import shutil
import string
import copy
from datetime import datetime
import webbrowser
from math import floor,cos,radians
from PyQt5 import QtCore, QtGui, QtWidgets
from PyQt5.QtCore import *
from PyQt5.QtGui import *
from PyQt5.QtWidgets import *
from shapely.geometry import LineString,Point,Polygon
from specifyMap import SpecifyMapDialog
from debrief_ui import Ui_DebriefDialog
from debriefOptionsDialog_ui import Ui_DebriefOptionsDialog
from appTracksDialog_ui import Ui_AppTracksDialog
import sys
sartopo_python_dir='../sartopo_python/sartopo_python'
if os.path.isdir(sartopo_python_dir):
sys.path.insert(1,sartopo_python_dir)
from sartopo_python import SartopoSession
LINK_LIGHT_STYLES={
-1:"background-color:#bb0000;", # red - no link / link error
0:"background-color:#aaaaaa;", # gray - no link attempted
1:"background-color:#009900;", # medium green - good link
2:"background-color:#ff9633;", # orange - sync stopped/paused
5:"background-color:#00dd00;", # med-light green - sync heartbeat
10:"background-color:#00ff00;", # light green - good link, sync in progress
100:"background-color:#00ffff;" # cyan - data change in progress
}
BASEMAP_REGEX={
'mapbuilder topo':'mbt',
'mapbuilder hybrid':'mbh',
'scanned topo':'t',
'forest service.*2016.*green':'f16a',
'forest service.*2016.*white':'f16',
'forest service.*2013':'f',
'naip':'n'
}
# This class defines the dialog structure.
# How should the content be updated? Options:
# - pushed from code that imports and instantiates this dialog:
# this code does not need any smarts to populate the dialog fields
# this makes sense if the DMG is running on the same computer,
# but what if it's running on a different computer?
# - pulled from an associated debrief map generator object:
# this code needs the smarts to populate the dialog fields
# - pulled from the debrief map, when the DMG process is not running locally:
# this code needs the smarts to parse the debrief map AND
# the smarts to populate the dialog fields
# Maybe this code should be the parent of the DMG object, i.e. the DMG
# object / process should be spawned from a button on this dialog.
# log uncaught exceptions - https://stackoverflow.com/a/16993115/3577105
# don't try to print from inside this function, since stdout is in binary mode
# note - this function will be overwritten by the same function in plans console
# (if called from plans console)
def handle_exception(exc_type, exc_value, exc_traceback):
if issubclass(exc_type, KeyboardInterrupt):
sys.__excepthook__(exc_type, exc_value, exc_traceback)
return
logging.critical('Uncaught exception', exc_info=(exc_type, exc_value, exc_traceback))
inform_user_about_issue('Uncaught Exception:')
sys.excepthook = handle_exception
# sourceMap and targetMap arguments can be one of:
# SartopoSession instance
# map ID (end of URL)
# complete URL
# in the last two cases, a new instance of SartopoSession will be created
# log filename should be <top-level-module-name>.log
# so if this is being called from plans console, use the already-opened
# logfile plans-console.log
# but if this is being run directly, use dmg.log
import common
if not common.logfile:
common.logfile=os.path.splitext(os.path.basename(sys.path[0]))[0]+'.log'
# To redefine basicConfig, per stackoverflow.com/questions/12158048
# Remove all handlers associated with the root logger object.
errlogdepth=5
for handler in logging.root.handlers[:]:
logging.root.removeHandler(handler)
if os.path.isfile(common.logfile):
os.remove(common.logfile)
logging.basicConfig(
level=logging.INFO,
datefmt='%H:%M:%S',
format='%(asctime)s [%(module)s:%(lineno)d:%(levelname)s] %(message)s',
handlers=[
# setting the filehandeler to write mode here causes the file
# to get deleted and overwritten when the threads end; so
# instead set it to append here, and take care of deleting it
# or rotating it at the top level
logging.FileHandler(common.logfile,'a'),
# logging.FileHandler(self.fileNameBase+'_bg.log','w'),
logging.StreamHandler(sys.stdout)
]
)
# add a custom handler that doesn't print anything, but instead copies the file
# to a backup if level is ERROR or CRITICAL. It's important to make sure this
# happens >after< the first default handler that actually does the printing.
# Only keep [logdepth] error log files (default 5).
errlog=False
class CustomHandler(logging.StreamHandler):
def emit(self,record):
if record.levelname in ['ERROR','CRITICAL']:
global errlog
# if this is the first error/critical record for this session,
# rotate the error log files in preparation for copying of the current log
if not errlog:
for n in range(errlogdepth-1,0,-1):
src=common.logfile+'.err.'+str(n)
dst=common.logfile+'.err.'+str(n+1)
if os.path.isfile(src):
os.replace(src,dst)
src=common.logfile+'.err'
dst=common.logfile+'.err.1'
if os.path.isfile(src):
os.replace(src,dst)
errlog=True
# if this session has had any error/critical records, copy to error log file
# (regardless of the current record's level)
if errlog:
shutil.copyfile(common.logfile,common.logfile+'.err')
logging.root.addHandler(CustomHandler())
def genLpix(ldpi):
lpix={}
for ptSize in [1,2,3,4,6,8,9,10,11,12,14,16,18,22,24,36,48]:
lpix[ptSize]=floor((ldpi*ptSize)/72)
return lpix
def ask_user_to_confirm(question: str, yesLabel=None, noLabel=None, icon: QMessageBox.Icon = QMessageBox.Question, parent: QObject = None, title = "Please Confirm") -> bool:
opts = Qt.WindowTitleHint | Qt.WindowCloseButtonHint | Qt.Dialog | Qt.MSWindowsFixedSizeDialogHint | Qt.WindowStaysOnTopHint
box = QMessageBox(icon, title, question, QMessageBox.NoButton, parent, opts)
yesLabel=yesLabel or 'Yes'
noLabel=noLabel or 'No'
yesButton=box.addButton(yesLabel,QMessageBox.YesRole)
box.addButton(noLabel,QMessageBox.NoRole)
# determine logical pixel equivalents: take prom parent if possible, so that the messagebox uses the same DPI as the spawning window
if hasattr(parent,'lpix'):
lpix=parent.lpix
# logging.info('using parent lpix: '+str(lpix))
else:
lpix=genLpix(96) # use 96dpi as a default when the parent doesn't have any lpix attribute
# logging.info('using default 96dpi lpix: '+str(lpix))
box.setDefaultButton(QMessageBox.No)
box.setStyleSheet('''
*{
font-size:'''+str(lpix[12])+'''px;
icon-size:'''+str(lpix[36])+'''px '''+str(lpix[36])+'''px;
}''')
box.show()
QCoreApplication.processEvents()
box.raise_()
box.exec_()
return box.clickedButton()==yesButton
def inform_user_about_issue(message: str, icon: QMessageBox.Icon = QMessageBox.Critical, parent: QObject = None, title="", timeout=0):
opts = Qt.WindowTitleHint | Qt.WindowCloseButtonHint | Qt.Dialog | Qt.MSWindowsFixedSizeDialogHint | Qt.WindowStaysOnTopHint
if title == "":
title = "Warning" if (icon == QMessageBox.Warning) else "Error"
buttons = QMessageBox.StandardButton(QMessageBox.Ok)
box = QMessageBox(icon, title, message, buttons, parent, opts)
# determine logical pixel equivalents: take prom parent if possible, so that the messagebox uses the same DPI as the spawning window
if hasattr(parent,'lpix'):
lpix=parent.lpix
# logging.info('using parent lpix: '+str(lpix))
else:
lpix=genLpix(96) # use 96dpi as a default when the parent doesn't have any lpix attribute
# logging.info('using default 96dpi lpix: '+str(lpix))
# attempt to set larger min width on hi res - none of these seem to work
# from https://www.qtcentre.org/threads/22298-QMessageBox-Controlling-the-width
# spacer=QSpacerItem(int(8000*(LDPI/96)),0,QSizePolicy.Minimum,QSizePolicy.Expanding)
# layout=box.layout()
# layout.addItem(spacer,layout.rowCount(),0,1,layout.columnCount())
# box.setMaximumWidth(int(800*(LDPI/96)))
# box.setFixedWidth(int(800*(LDPI/96)))
box.setStyleSheet('''
*{
font-size:'''+str(lpix[12])+'''px;
icon-size:'''+str(lpix[36])+'''px '''+str(lpix[36])+'''px;
}''')
box.show()
QCoreApplication.processEvents()
box.raise_()
if timeout:
QTimer.singleShot(timeout,box.close)
box.exec_()
# from https://stackoverflow.com/a/10995203/3577105
def dictHasAllKeys(d,klist):
if not isinstance(d,dict) or not isinstance(klist,list):
logging.error('dictHasKeys: first arg must be dict; second arg must be list')
return False
return all(key in d for key in klist)
def isApptrackSubsetOfLine(lineCoords,apptrackCoords):
# comparisons will be easier if we make a dictionary: keys are timestamps, values are [lon,lat] (ignore elevation)
# but we need to do a sort again before it's readable
lineTSDict={v[3]:v[0:2] for v in lineCoords}
apptrackTSDict={v[3]:v[0:2] for v in apptrackCoords}
# this could be a one-liner since we only need to check if one dict is subset of the other (sequence doesn't matter)
# but this way we have access to internal statistics if needed
subset=True
matches=0
tsDifferencesList=[]
lineTsMissingFromApptrackList=[]
for ts in lineTSDict.keys():
if ts in apptrackTSDict.keys():
if lineTSDict[ts]==apptrackTSDict[ts]:
matches+=1
else:
subset=False
tsDifferencesList.append(ts)
else:
lineTsMissingFromApptrackList.append(ts)
if matches==0:
subset=False
# logging.info('matches:'+str(matches))
# logging.info('tsDifferences:'+str(len(tsDifferencesList)))
# # logging.info('lineTsMissingFromApptrackList:'+str(len(lineTsMissingFromApptrackList))+':'+str(lineTsMissingFromApptrackList))
# logging.info('lineTsMissingFromApptrackList:'+str(len(lineTsMissingFromApptrackList)))
return subset
class DebriefMapGenerator(QObject):
updateLinkLightsSignal=pyqtSignal()
def __init__(self,parent,sourceMap,targetMap):
logging.info('Debrief Map Generator startup at '+datetime.now().strftime("%a %b %d %Y %H:%M:%S"))
self.parent=parent
super(DebriefMapGenerator,self).__init__()
# is this being spawned from Plans Console?
self.pc=self.parent.__class__.__name__=='PlansConsole'
self.debriefURL=''
self.startupBox=None
self.excludedFolderIDs=[]
self.excludedFolderTitles=['scratch','aTEAMS','Tracks to location']
self.excludedFolderTitles=[x.lower() for x in self.excludedFolderTitles] # all lowercase, for comparison later
# do not register the callbacks until after the initial processing; that way we
# can be sure to process existing assignments first
# assignments - dictionary of dictionaries of assignment data; each assignment sub-dictionary
# is created upon processing of the first feature that appears to be related to the assigment,
# based on title ('AA101' for assignment features, or 'AA101a' for lines, possibly including space(s))
# NOTE - we really want to be able to recreate the ID associations at runtime (what debrief map
# feature corresponds to what source map feature) rather than relying on a correspondence file,
# but that would require a place for metadata on the debrief map features. Maybe the debrief map
# details / description field could be used for this? Any new field added here just gets deleted
# by sartopo. How important is it to keep this ID correspondence? IS title sufficient?
# key = assignment title (name and number; i.e. we want one dict entry per pairing/'outing')
# val = dictionary
# bid - id of boundary feature (in the debrief map)
# fid - id of folder feature (in the debrief map)
# sid - id of the assignment feature (in the SOURCE map)
# cids - list of ids of associated clues (in the debrief map)
# tids - list of ids of associated tracks (in the debrief map)
# utids - list of uncropped track ids (since the track may be processed before the boundary)
self.dd=DebriefDialog(self)
if self.parent.debriefX and self.parent.debriefY and self.parent.debriefW and self.parent.debriefH:
self.dd.setGeometry(int(self.parent.debriefX),int(self.parent.debriefY),int(self.parent.debriefW),int(self.parent.debriefH))
self.debriefOptionsDialog=DebriefOptionsDialog(self)
self.dd.ui.debriefOptionsButton.clicked.connect(self.debriefOptionsButtonClicked)
self.appTracksDialog=AppTracksDialog(self)
self.dd.ui.appTracksButton.clicked.connect(self.appTracksButtonClicked)
self.dd.ui.pauseIcon=QtGui.QIcon()
self.dd.ui.pauseIcon.addPixmap(QtGui.QPixmap(":/plans_console/pause.png"),QtGui.QIcon.Normal,QtGui.QIcon.Off)
self.dd.ui.startIcon=QtGui.QIcon()
self.dd.ui.startIcon.addPixmap(QtGui.QPixmap(":/plans_console/play-icon.png"),QtGui.QIcon.Normal,QtGui.QIcon.Off)
self.dd.ui.debriefPauseResumeButton.clicked.connect(self.debriefPauseResumeButtonClicked)
self.debriefHeaderTextPart1={
'on':'Debrief Map Generator is running in the background. You can safely close and reopen this dialog as needed.',
'off':'Syncing is currently PAUSED. Data in the debrief table below may be out of date. Click the Play button to resume.',
'ended':'Syncing has ENDED, due to shutdown or due to fatal exception. Check the log file to find out why. Data in the debrief table below may be out of date. Click the Play button to restart sync.'
}
self.debriefHeaderTextPart2='\n\nDebrief data (tracks from returning searchers) should be imported to the INCIDENT map. The DEBRIEF map is automatically updated and should not need to be directly edited.'
self.debriefHeaderTextPart2+='\n\nUnfinished AppTracks (indicated after a plus sign in track counts below) will NOT show up in the SARTopo debrief map, but they WILL show up with a dashed line in generated PDFs. Click the AppTracks button for details.'
# determine / create SartopoSession objects
# process the target session first, since nocb definition checks for it
# determine / create sts2 (debrief map SartopoSession instance)
self.sts2=None
self.debriefDomainAndPort=None
tcn=targetMap.__class__.__name__
if tcn=='SartopoSession':
# logging.info('debrief map argument = SartopoSession instance')
self.sts2=targetMap
self.debriefDomainAndPort=self.sts2.domainAndPort
self.debriefMapID=self.sts2.mapID
elif tcn=='str':
# logging.info('debrief map argument = string')
self.debriefDomainAndPort='localhost:8080'
targetParse=targetMap.split('/')
self.debriefMapID=targetParse[-1]
self.debriefURL=self.debriefDomainAndPort+'/m/'+self.debriefMapID
if targetMap.lower().startswith('http'):
self.debriefURL=targetMap
self.debriefDomainAndPort=targetParse[2]
else:
logging.info('No debrief map; raising SpecifyMapDialog')
self.defaultDomainAndPort=None
if hasattr(self.parent,'defaultDomainAndPort'):
self.defaultDomainAndPort=self.parent.defaultDomainAndPort
self.debriefMapDialog=SpecifyMapDialog(self,'Debrief','Debrief Map:\nCreate New Map, or Use Existing Map?',self.defaultDomainAndPort,enableNewMap=True,newDefault=True)
if self.debriefMapDialog.exec(): # force modal
if '.com' in self.debriefMapDialog.dap:
if not ask_user_to_confirm('Internet sites are not recommended for the debrief map. The debrief map should be hosted on the local node or an intranet server running CalTopo Desktop, if at all possible.\n\nDMG makes a lot of network requests; an internet debrief map could result in poor performace for others on the same network.\n\nUse an internet debrief map anyway?'):
return
if self.debriefMapDialog.newMap:
if '.com' in self.debriefMapDialog.dap:
inform_user_about_issue('New map creation on internet sites is not supported. Use an existing internet map, or use localhost or an intranet server instead.')
return
logging.info('new map requested')
configpath='../sts.ini' # default; overridden when defined in plans console
account=None
self.debriefDomainAndPort=self.debriefMapDialog.dap
if self.pc:
configpath=self.parent.stsconfigpath
account=self.parent.accountName
self.startupBox=QMessageBox(
QMessageBox.NoIcon, # other vaues cause the chime sound to play
'New debrief map...',
'Debrief Map:\n\nCreating new map...')
self.startupBox.setStandardButtons(QMessageBox.NoButton)
self.startupBox.show()
QCoreApplication.processEvents()
try:
self.sts2=SartopoSession(self.debriefDomainAndPort,'[NEW]',
sync=False,
account=account,
configpath=configpath,
syncTimeout=10)
except:
inform_user_about_issue('New map request failed. See the log for details. You can try to create a new map on a different host, or, you can use an existing map.')
self.startupBox.done(0)
return
self.debriefMapID=self.sts2.mapID
self.debriefURL=self.debriefMapDialog.url.replace('<Pending>',self.sts2.mapID)
self.startupBox.setText('New map created:\n\n'+self.debriefURL+'\n\nPopulating new map...')
QCoreApplication.processEvents()
else:
self.debriefURL=self.debriefMapDialog.url
parse=self.debriefURL.replace("http://","").replace("https://","").split("/")
self.debriefDomainAndPort=parse[0]
self.debriefMapID=parse[-1]
self.dd.ui.debriefMapField.setText(self.debriefURL)
if self.pc:
self.parent.ui.debriefMapField.setText(self.debriefURL)
else:
return # debrief map selection dialog was canceled
if not self.sts2:
box=QMessageBox(
QMessageBox.NoIcon, # other vaues cause the chime sound to play
'Connecting...',
'Debrief Map:\n\nConnecting to '+self.debriefURL+'\n\nPlease wait...')
box.setStandardButtons(QMessageBox.NoButton)
box.show()
configpath='../sts.ini' # default - overridden when defined in plans console
account=None
if self.pc:
configpath=self.parent.stsconfigpath
account=self.parent.accountName
QCoreApplication.processEvents()
box.raise_()
# parse=self.debriefURL.replace("http://","").replace("https://","").split("/")
# domainAndPort=parse[0]
# mapID=parse[-1]
self.sts2=SartopoSession(self.debriefDomainAndPort,self.debriefMapID,
sync=False,
account=account,
configpath=configpath,
syncTimeout=10)
# syncTimeout=10,
# syncDumpFile='../../'+self.debriefMapID+'.txt')
box.done(0)
if self.sts2 and self.sts2.apiVersion<0:
p=self.dd
if self.pc:
p=self.parent
inform_user_about_issue('Link could not be established with specified debrief map\n\n'+self.debriefURL+'\n\nPlease specify a valid map.',parent=p)
return
if self.pc:
self.dd.ui.debriefDialogLabel.setText(self.debriefHeaderTextPart1['on']+self.debriefHeaderTextPart2)
self.parent.debriefURL=self.debriefURL
# determine / create sts1 (source map SartopoSession instance)
self.sts1=None
scn=sourceMap.__class__.__name__
if scn=='SartopoSession':
logging.info('Source map argument = SartopoSession instance: '+sourceMap.domainAndPort+'/m/'+sourceMap.mapID)
self.sts1=sourceMap
self.sourceMapID=self.sts1.mapID
self.incidentDomainAndPort=self.sts1.domainAndPort
elif scn=='str':
logging.info('Source map argument = string')
self.incidentDomainAndPort='localhost:8080'
sourceParse=sourceMap.split('/')
self.sourceMapID=sourceParse[-1]
if sourceMap.lower().startswith('http'):
self.incidentDomainAndPort=sourceParse[2]
try:
self.sts1=SartopoSession(self.incidentDomainAndPort,self.sourceMapID,
# syncDumpFile='../../'+self.sourceMapID+'.txt',
# newFeatureCallback=self.initialNewFeatureCallback,
# propertyUpdateCallback=self.propertyUpdateCallback,
# geometryUpdateCallback=self.geometryUpdateCallback,
# deletedFeatureCallback=self.deletedFeatureCallback,
syncTimeout=10)
except:
logging.critical('Error during source map session creation; aborting.')
sys.exit()
else:
logging.critical('No source map.')
return
if self.sts1:
self.sts1.syncCallback=self.syncCallback
if self.pc:
self.dd.ui.incidentMapField.setText(self.parent.incidentURL)
# self.sourceMapID=sourceMapID
# self.debriefMapID=debriefMapID # must already be a saved map
self.fileNameBase=self.sourceMapID+'_'+self.debriefMapID
self.dmdFileName='dmg_'+self.fileNameBase+'.json'
self.debriefDir='.' # default
self.pdfDir='.' # default
self.dmdDir='.' # default
self.pdfDir2=None # default
if self.pc:
self.debriefDir=os.path.join(common.pcDir,'Debrief')
self.dmdDir=os.path.join(self.debriefDir,'JSON')
self.pdfDir=os.path.join(self.debriefDir,'Maps')
if not os.path.isdir(self.dmdDir):
os.makedirs(self.dmdDir)
self.dmdFileName=os.path.join(self.dmdDir,self.dmdFileName)
try:
cd=self.parent.config['Debrief']
self.pdfDir=cd.get('pdfDir',self.pdfDir)
self.pdfDir2=cd.get('pdfDir2')
except:
logging.warning('Debrief section was not found / could not be read from '+self.parent.configFileName+'; generated PDF files will be written to the default directory '+self.pdfDir)
if not os.path.isdir(self.pdfDir):
try:
logging.info("Creating PDF dir "+self.pdfDir)
os.makedirs(self.pdfDir)
except:
failedDir=self.pdfDir
self.pdfDir='.'
logging.error("ERROR creating PDF directory "+failedDir+"; generated PDFs will be written to the default directory '"+self.pdfDir+"'.")
if self.pdfDir2 and not os.path.isdir(self.pdfDir2):
try:
logging.info("Creating second PDF dir "+self.pdfDir2)
os.makedirs(self.pdfDir2)
except:
logging.error("ERROR creating second PDF directory "+self.pdfDir2+"; new PDF copy to that location will still be attempted for each generated PDF.")
# assignmentsFileName=fileNameBase+'_assignments.json'
# different logging level for different modules:
# https://stackoverflow.com/a/7243225/3577105
logging.getLogger('sartopo_python').setLevel(logging.DEBUG)
# rotate track colors: red, green, blue, orange, cyan, purple, then darker versions of each
self.trackColorDict={
'a':'#FF0000',
'b':'#00CD00',
'c':'#0000FF',
'd':'#FFAA00',
'e':'#009AFF',
'f':'#A200FF',
'g':'#C00000',
'h':'#006900',
'i':'#0000C0',
'j':'#BC7D00',
'k':'#0084DC',
'l':'#8600D4'} # default specified in .get function
self.roamingThresholdMeters=50
self.cropDegrees=0.001 # about 100 meters - varies with latitude but this is not important for cropping
self.roamingCropDegrees=0.1 # about 10km - varies with latitude but this is not important for cropping
self.dmd={'outings':{},'corr':{},'unclaimedTracks':{},'unclaimedClues':{},'appTracks':{}} # master map data and correspondence dictionary - short for 'Debrief Map Dictionary'
# self.dmd['outings']={}
# self.dmd['corr']={}
self.writeDmdPause=False
# self.pdfStatus={}
# self.dmd['unclaimed']={}
self.outingSuffixDict={} # index numbers for duplicate-named assignments
# def writeAssignmentsFile():
# # write the correspondence file
# with open(assignmentsFileName,'w') as assignmentsFile:
# assignmentsFile.write(json.dumps(assignments,indent=3))
# # open a session on the debrief map first, since nocb definition checks for it
# if not self.sts2:
# try:
# self.sts2=SartopoSession(self.debriefDomainAndPort,self.debriefMapID,
# sync=False,
# syncTimeout=10,
# syncDumpFile='../../'+self.debriefMapID+'.txt')
# except:
# sys.exit()
# if not self.sts1:
# try:
# self.sts1=SartopoSession(self.incidentDomainAndPort,self.sourceMapID,
# syncDumpFile='../../'+self.sourceMapID+'.txt',
# # newFeatureCallback=self.initialNewFeatureCallback,
# # propertyUpdateCallback=self.propertyUpdateCallback,
# # geometryUpdateCallback=self.geometryUpdateCallback,
# # deletedFeatureCallback=self.deletedFeatureCallback,
# syncTimeout=10)
# except:
# sys.exit()
# wait for the source map sync to complete before trying to read an existing dmd file,
# otherwise all correspondences will be invalid because the sid's are not yet in
# the source cache
logging.info('sts1.apiVersion:'+str(self.sts1.apiVersion))
logging.info('sts2.apiVersion:'+str(self.sts2.apiVersion))
if self.startupBox:
self.startupBox.done(0)
if self.sts1.apiVersion>=0 and self.sts2.apiVersion>=0:
logging.info('Initial feature processing begins.')
self.sts1.refresh() # this should do a blocking refresh
# block since requests for both maps (triggered by getFeatures) during initial processing
self.sts1.syncing=True
self.sts2.syncing=True
self.initDmd()
# now that dmd is generated, all source map features should be passed to newFeatureCallback,
# which is what would happen if the callback were registered when sts1 was created - but
# that would be too early, since the feature creation functions rely on dmd
mdsf=self.sts1.mapData['state']['features']
fc=len(mdsf)
progressBox=QProgressDialog('Processing incident map features, please wait...',"Abort",0,100)
progressBox.setMaximum(fc)
progress=1
# progressBox.setWindowModality(Qt.WindowModal)
progressBox.setWindowTitle("Initializing")
progressBox.show()
progressBox.raise_()
QCoreApplication.processEvents()
for f in mdsf:
self.newFeatureCallback(f)
progress+=1
progressBox.setValue(progress)
QCoreApplication.processEvents()
progressBox.close()
logging.info('Initial feature processing completed.')
# unblock since requests now that initial processing is done
self.sts1.syncing=False
self.sts2.syncing=False
# don't register the callbacks until after the initial refresh dmd file processing,
# to prevent duplicate feature creation in the debrief map on restart
self.sts1.newFeatureCallback=self.newFeatureCallback
self.sts1.propertyUpdateCallback=self.propertyUpdateCallback
self.sts1.geometryUpdateCallback=self.geometryUpdateCallback
self.sts1.deletedFeatureCallback=self.deletedFeatureCallback
if not self.sts1.sync:
self.sts1.start()
# updateLinnkLightsSignal, emitted from thread-safe updateLinkLights function,
# calls _updateLinkLights slot which always runs in the main thread
# therefore will not cause crashes
self.updateLinkLightsSignal.connect(self._updateLinkLights)
self.updateLinkLights()
self.redrawFlag=True
self.appTracksDialogRedrawFlag=False
self.syncBlinkFlag=False
self.mainTimer=QTimer()
self.mainTimer.timeout.connect(self.tick)
self.mainTimer.start(1000)
self.prevPauseManual=False
# need to run this program in a loop - it's not a background/daemon process
# while True:
# time.sleep(5)
# logging.info('dmd:\n'+str(json.dumps(self.dmd,indent=3)))
# return # why would it need to run in a loop? Maybe that was tru before it was QtIzed
# updateLinkLights - can safely be called from within the background thread:
# sets instance variables, and sends the signal to update the link lights
def updateLinkLights(self,incidentLink=None,debriefLink=None):
self.incidentLightColor=incidentLink or self.sts1.apiVersion
self.debriefLightColor=debriefLink or self.sts2.apiVersion
self.updateLinkLightsSignal.emit()
# _udpateLinkLights - calling this from a background thread can cause hard-to-debug crashes!
# so, it should only be called from the signal emitted by a call to updateLinkLights (no underscore)
def _updateLinkLights(self):
if self.incidentLightColor: # leave it unchanged if the variable is None
self.dd.ui.incidentLinkLight.setStyleSheet(LINK_LIGHT_STYLES[self.incidentLightColor])
if self.pc:
self.parent.ui.incidentLinkLight.setStyleSheet(LINK_LIGHT_STYLES[self.incidentLightColor])
if self.sts2 and self.debriefLightColor: # leave it unchanged if the variable is None
self.dd.ui.debriefLinkLight.setStyleSheet(LINK_LIGHT_STYLES[self.debriefLightColor])
if self.pc:
self.parent.ui.debriefLinkLight.setStyleSheet(LINK_LIGHT_STYLES[self.debriefLightColor])
def writeDmdFile(self):
if not self.writeDmdPause:
with open(self.dmdFileName,'w') as dmdFile:
dmdFile.write(json.dumps(self.dmd,indent=3))
self.redrawFlag=True
def tick(self):
if self.redrawFlag:
logging.debug('Debrief redraw was requested; redrawing the debrief table...')
row=0
self.dd.ui.tableWidget.setSortingEnabled(False)
outings=self.dmd.get('outings',None)
self.dd.ui.tableWidget.setRowCount(len(outings))
for outingName in outings:
appTrackCount=len([atid for atid in self.dmd['appTracks'] if self.dmd['appTracks'][atid][1]==outingName])
o=self.dmd['outings'][outingName]
trackCountText=str(len(o['tids'])+len(o['utids']))
if appTrackCount>0:
trackCountText+=' + '+str(appTrackCount)
self.dd.ui.tableWidget.setItem(row,0,QTableWidgetItem(outingName))
self.dd.ui.tableWidget.setItem(row,1,QTableWidgetItem(trackCountText))
self.dd.ui.tableWidget.setItem(row,2,QTableWidgetItem(str(len(o['cids']))))
editNoteButton=QPushButton(self.dd.ui.editNoteIcon,'')
editNoteButton.setIconSize(QSize(self.lpix[16],self.lpix[16]))
editNoteButton.clicked.connect(self.editNoteClicked)
self.dd.ui.tableWidget.setCellWidget(row,3,editNoteButton)
notes=o.get('notes',None)
if notes:
s='\n'.join(list(reversed(notes)))
i=QTableWidgetItem(s)
tt='<table border="1" cellpadding="3">'
for note in [x for x in notes if x!='']:
tt+='<tr><td>'+note+'</td></tr>'
tt+='</table>'
i.setToolTip(tt)
self.dd.ui.tableWidget.setItem(row,4,i)
pdf=o.get('PDF',None)
if pdf:
pdfts=o['PDF'][1]
latestts=o['log'][-1][0]
if pdfts>latestts:
# pdf was generated more recently than the latest modification of this outing's data
self.setPDFButton(row,'done')
else:
self.setPDFButton(row,'old')
else:
self.setPDFButton(row,'gen')
rebuildButton=QPushButton(self.dd.ui.rebuildIcon,'')
rebuildButton.setIconSize(QSize(self.lpix[16],self.lpix[16]))
rebuildButton.clicked.connect(self.rebuildClicked)
self.dd.ui.tableWidget.setCellWidget(row,6,rebuildButton)
row+=1
vh=self.dd.ui.tableWidget.verticalHeader()
for n in range(self.dd.ui.tableWidget.columnCount()):
vh.resizeSection(n,self.dd.lpix[16])
self.dd.ui.tableWidget.viewport().update()
self.dd.moveEvent(None) # initialize sizes
self.dd.ui.tableWidget.setSortingEnabled(True)
self.dd.ui.tableWidget.sortItems(0)
self.redrawFlag=False
if self.sts1.syncPauseManual!=self.prevPauseManual:
self.prevPauseManual=self.sts1.syncPauseManual
if self.sts1.syncPauseManual:
self.dd.ui.debriefDialogLabel.setText(self.debriefHeaderTextPart1['off']+self.debriefHeaderTextPart2)
self.dd.ui.debriefPauseResumeButton.setIcon(self.dd.ui.startIcon)
self.dd.ui.debriefPauseResumeButton.setToolTip('Resume Sync')
self.dd.ui.debriefLinkLight.setStyleSheet(LINK_LIGHT_STYLES[2])
if self.pc:
self.parent.ui.debriefLinkLight.setStyleSheet(LINK_LIGHT_STYLES[2])
self.dd.ui.tableWidget.setStyleSheet('background-color:#FFAAAA;')
else:
self.dd.ui.debriefDialogLabel.setText(self.debriefHeaderTextPart1['on']+self.debriefHeaderTextPart2)
self.dd.ui.debriefPauseResumeButton.setIcon(self.dd.ui.pauseIcon)
self.dd.ui.debriefPauseResumeButton.setToolTip('Pause Sync')
self.dd.ui.debriefLinkLight.setStyleSheet(LINK_LIGHT_STYLES[1])
if self.pc:
self.parent.ui.debriefLinkLight.setStyleSheet(LINK_LIGHT_STYLES[1])
self.dd.ui.tableWidget.setStyleSheet('background-color:#FFFFFF;')
if self.syncBlinkFlag: # set by syncCallback after each sync
self.dd.ui.tableWidget.setStyleSheet('background-color:#FFFFFF;')
self.updateLinkLights(incidentLink=5)
QTimer.singleShot(500,self.updateLinkLights)
self.syncBlinkFlag=False
if not self.sts1.sync: # this should only be the case when sync has ended, due to shutdown or exception
self.dd.ui.debriefDialogLabel.setText(self.debriefHeaderTextPart1['ended']+self.debriefHeaderTextPart2)
self.dd.ui.debriefPauseResumeButton.setIcon(self.dd.ui.startIcon)
self.dd.ui.debriefPauseResumeButton.setToolTip('Restart Sync')
self.dd.ui.debriefLinkLight.setStyleSheet(LINK_LIGHT_STYLES[-1])
if self.pc:
self.parent.ui.debriefLinkLight.setStyleSheet(LINK_LIGHT_STYLES[-1])
self.dd.ui.tableWidget.setStyleSheet('background-color:#FFAAAA;')
if self.appTracksDialogRedrawFlag:
self.appTracksDialogRedrawFlag=False
self.appTracksDialogRedraw()
def setPDFButton(self,outingNameOrRow,state):
if isinstance(outingNameOrRow,int):
row=outingNameOrRow
outingName=self.dd.ui.tableWidget.item(row,0).text()
elif isinstance(outingNameOrRow,str):
outingName=outingNameOrRow
row=-1
if not outingName in self.dmd['outings'].keys():
return False
for n in range(self.dd.ui.tableWidget.rowCount()):
v=self.dd.ui.tableWidget.item(n,0).text()
logging.info('checking '+str(n)+': "'+str(v)+'"')
if v==outingName:
logging.info(' match!')
row=n
break
if row<0:
logging.error('Call to setPDFButton but outing name "'+outingNameOrRow+'" was not found in the debrief outings table.')
return False
# state=changed: specified during callbacks, which aren't aware if a pdf has already been generated
if state=='changed':
[lastPDFCode,lastPDFTime]=self.dmd['outings'][outingName].get('PDF',[None,None])
if lastPDFCode: # a PDF has previously been generated; set the icon to 'regen'
state='old'
else:
state='gen'
# state=gen: no pdf has been generated; show the initial black-arrow icon
if state=='gen':
icon=self.dd.ui.generatePDFIcon
slot=self.PDFGenClicked
# state=done: pdf successfully generated and (apparently) up-to-date; show the checked icon
elif state=='done':
icon=self.dd.ui.generatePDFDoneIcon
slot=self.PDFDoneClicked
# state=old: pdf previously generated but now out-of-date; show the refresh circle icon
elif state=='old':
icon=self.dd.ui.generatePDFRegenIcon
slot=self.PDFRegenClicked
button=QPushButton(icon,'')
button.setIconSize(QSize(self.lpix[36],self.lpix[14]))
# genPDFButton.icon().setSizePolicy(QSizePolicy.Expanding,QSizePolicy.Preferred)
button.clicked.connect(slot)
self.dd.ui.tableWidget.setCellWidget(row,5,button)
def syncCallback(self):
# this function is probably called from a sync thread:
# can't create a timer or do some GUI operations from here, etc.
self.syncBlinkFlag=True
if self.appTracksDialog.isVisible():
self.appTracksDialogRedrawFlag=True
def debriefOptionsButtonClicked(self,*args,**kwargs):
self.debriefOptionsDialog.show()
self.debriefOptionsDialog.raise_()
def appTracksButtonClicked(self,*args,**kwargs):
self.appTracksDialogRedraw()
self.appTracksDialog.show()
self.appTracksDialog.raise_()
def appTracksDialogRedraw(self):
row=[0,0,0]
tsNow=time.time()
self.appTracksDialog.ui.tableWidgetAssociatedUnfinished.setSortingEnabled(False)
self.appTracksDialog.ui.tableWidgetAssociatedFinished.setSortingEnabled(False)
self.appTracksDialog.ui.tableWidgetUnassociated.setSortingEnabled(False)
associatedAppTracks=[x for x in self.dmd['appTracks'].values() if x[1] is not None and x[1]!='[SUBSET]']
associatedFinishedCount=len([x for x in associatedAppTracks if '[FINISHED]' in x[1]])
associatedUnfinishedCount=len(associatedAppTracks)-associatedFinishedCount
unassociatedCount=len([x for x in self.dmd['appTracks'].values() if x[1] is None])
self.appTracksDialog.ui.ignoredListWidget.clear()
self.appTracksDialog.ui.tableWidgetAssociatedUnfinished.setRowCount(associatedUnfinishedCount)
self.appTracksDialog.ui.tableWidgetAssociatedFinished.setRowCount(associatedFinishedCount)
self.appTracksDialog.ui.tableWidgetUnassociated.setRowCount(unassociatedCount)
for atid in self.dmd['appTracks'].keys():
at=self.dmd['appTracks'][atid]
latestSec=int(tsNow)-int(at[2]/1000)
if latestSec<10:
latestStr='<10 sec.'
elif latestSec<30:
latestStr='<30 sec.'
elif latestSec<60:
latestStr='<1 min.'
elif latestSec<300:
latestStr='<5 mins.'
elif latestSec<600:
latestStr='<10 mins.'
elif latestSec<1800:
latestStr='<30 mins.'
elif latestSec<3600:
latestStr='<1 hr.'
elif latestSec<21600:
latestStr='<6 hrs.'
elif latestSec<86400:
latestStr='<1 day'
else:
latestStr='>1 day'
# comboOptions=['None']+sorted(self.dmd['outings'].keys(),key=str.casefold)
# combo=QComboBox()
# combo.setObjectName(atid) # for use in the callback
# for i in comboOptions:
# combo.addItem(i)
# # set combo box to reflect already-associated outing
# if self.dmd['appTracks'][atid][1] in comboOptions:
# combo.setCurrentText(self.dmd['appTracks'][atid][1])
# combo.currentTextChanged.connect(self.appTrackComboBoxChanged)
if at[1]:
if at[1]=='[SUBSET]':
self.appTracksDialog.ui.ignoredListWidget.addItem(at[0])
elif '[FINISHED]' in at[1]:
self.appTracksDialog.ui.tableWidgetAssociatedFinished.setItem(row[1],0,QTableWidgetItem(at[0]))
self.appTracksDialog.ui.tableWidgetAssociatedFinished.setItem(row[1],1,QTableWidgetItem(latestStr))
row[1]+=1
else:
self.appTracksDialog.ui.tableWidgetAssociatedUnfinished.setItem(row[0],0,QTableWidgetItem(at[0]))
self.appTracksDialog.ui.tableWidgetAssociatedUnfinished.setItem(row[0],1,QTableWidgetItem(at[1]))
self.appTracksDialog.ui.tableWidgetAssociatedUnfinished.setItem(row[0],2,QTableWidgetItem(latestStr))
row[0]+=1
else:
self.appTracksDialog.ui.tableWidgetUnassociated.setItem(row[2],0,QTableWidgetItem(at[0]))
self.appTracksDialog.ui.tableWidgetUnassociated.setItem(row[2],1,QTableWidgetItem(latestStr))
row[2]+=1
self.appTracksDialog.ui.tableWidgetAssociatedUnfinished.setSortingEnabled(True)
self.appTracksDialog.ui.tableWidgetAssociatedUnfinished.sortItems(0)
self.appTracksDialog.ui.tableWidgetAssociatedFinished.setSortingEnabled(True)
self.appTracksDialog.ui.tableWidgetAssociatedFinished.sortItems(0)
self.appTracksDialog.ui.tableWidgetUnassociated.setSortingEnabled(True)
self.appTracksDialog.ui.tableWidgetUnassociated.sortItems(0)
# def appTrackComboBoxChanged(self,newText):
# atid=self.sender().objectName()
# if newText!='None':
# self.dmd['appTracks'][atid][1]=newText
# else:
# self.dmd['appTracks'][atid][1]=None
# self.redrawFlag=True
# QTimer.singleShot(500,self.appTracksButtonClicked)
def debriefPauseResumeButtonClicked(self,*args,**kwargs):
if self.sts1.sync: # sync is on, but may be paused
if self.sts1.syncPauseManual: # syncing was paused: resume sync
self.sts1.resume()
else: # syncing was not paused: pause sync
self.sts1.pause()
else: # sync has ended, due to shutdown or exception
self.sts1.start()
self.dd.ui.debriefDialogLabel.setText(self.debriefHeaderTextPart1['on']+self.debriefHeaderTextPart2)
self.dd.ui.debriefPauseResumeButton.setIcon(self.dd.ui.pauseIcon)
self.dd.ui.debriefPauseResumeButton.setToolTip('Pause Sync')
# other GUI indications of sync status are handled by self.tick()
def editNoteClicked(self,*args,**kwargs):
row=self.dd.ui.tableWidget.currentRow()
outingName=self.dd.ui.tableWidget.item(row,0).text()
logging.info('edit note clicked for outing '+outingName)
rval=QInputDialog.getMultiLineText(self.dd,'Add Note','Add note for '+outingName+':')
if rval[1]:
text=rval[0]
notes=self.dmd['outings'][outingName].get('notes',[])
notes.append(text)
self.dmd['outings'][outingName]['notes']=notes
self.redrawFlag=True
self.writeDmdFile()
# logging.info('entered: '+str(text))
# twoify - turn four-element-vertex-data into two-element-vertex-data so that
# the shapely functions can operate on it
def twoify(self,points):
if not isinstance(points,list):
return points
if isinstance(points[0],list): # the arg is a list of points
return [p[0:2] for p in points]
else: # the arg is just one point
return points[0:2]
def PDFGenClicked(self,*args,**kwargs):
if not self.sts2.id:
inform_user_about_issue("'id' is not defined for the debrief map session; cannot generarte PDF.'",parent=self.dd)
return
if not self.sts2.key:
inform_user_about_issue("'key' is not defined for the debrief map session; cannot generarte PDF.'",parent=self.dd)
return
if not self.sts2.accountId:
inform_user_about_issue("'accountId' is not defined for the debrief map session; cannot generarte PDF.'",parent=self.dd)
return
row=self.dd.ui.tableWidget.currentRow()
outingName=self.dd.ui.tableWidget.item(row,0).text()
logging.info('Generate PDF button clicked for outing '+outingName)
outing=self.dmd['outings'][outingName]
outingFeatureIds=[outing['bid']]
outingFeatureIds.extend(outing['cids'])
alltids=[]
for tidList in outing['tids']:
alltids.extend(tidList)
outingFeatureIds+=alltids
# add feature(s) now for any unfinished apptracks associated with this outing
appTracksIDList=[id for id in self.dmd['appTracks'].keys() if self.dmd['appTracks'][id][1]==outingName]
allTrackTitles=[self.dmd['appTracks'][t][0] for t in appTracksIDList] # keep a list of titles of all associated lines and apptracks to check for duplicates
appTracksFeaturesUncropped=[] # used for legend generation
appTracksFeaturesCropped=[] # sent in the PDF request
cropDegrees=self.dmd['outings'][outingName].get('crop',self.cropDegrees)
for atid in appTracksIDList:
atf=self.sts1.getFeature(id=atid,featureClass='AppTrack')
atfp=atf['properties']
tparse=self.parseTrackName(atf['properties']['title'])
# atf['properties']['pattern']='M0 -3 L0 3,,12,F' # simple dashed line
# atf['properties']['pattern']='M0 -1 L0 1,,8,F' # simple dotted line
atfp['pattern']='M0 -3 L0 2,,8,F' # heavy dashed line
atfp['stroke-width']=4 # since dashed lines are thinner on PDF
atfp['stroke']=self.trackColorDict.get(tparse[2].lower(),'#444444')
logging.info('adding AppTrack '+atid+':\n'+json.dumps(atf,indent=3))
cropped=self.sts2.crop(atf,outing['bid'],beyond=cropDegrees,noDraw=True)
if cropped: # if target was entirely outside boundary, cropped result is False
for seg in cropped:
logging.info(' cropped segment:'+str(seg))
segf=copy.deepcopy(atf)
segf['geometry']['coordinates']=seg
appTracksFeaturesCropped.append(segf)
appTracksFeaturesUncropped.append(atf)
# croppedAppTrackList=self.sts2.crop(appTrackCoords,boundary)
# logging.info('ids for this outing:'+str(ids))
bounds=self.sts2.getBounds(outingFeatureIds,padPct=15)
# also print non-outing-related features
# while there could be folders of non-outing-related features in the incident map,
# the debrief map should have no folders other than outings, so just checking
# for fetures that are not in folders should be sufficient
nonOutingFeatureIds=[f['id'] for f in self.sts2.mapData['state']['features']
if 'folderId' not in f['properties'].keys()
and f['properties']['class'].lower() in ['marker','shape']]
# logging.info('non-outing ids:'+str(nonOutingFeatureIds))
ids=outingFeatureIds+nonOutingFeatureIds
lonMult=cos(radians((bounds[3]+bounds[1])/2.0))
# logging.info('longitude multiplier = '+str(lonMult))
# determine orientation from initial aspect ratio, then snap the bounds to
# letter-size aspect ratio (map tiles will only be rendered for this area)
# calculate width, height, and aspect ratio in rectangular units, as they
# would appear on paper or on screen. 1' x 1' rectange will appear
# taller than it is wide, so w should be <1:
w=(bounds[2]-bounds[0])*lonMult
h=bounds[3]-bounds[1]
ar=w/h
# logging.info('bounds before adjust (ar='+str(round(ar,4))+') : '+str(bounds))
if ar>1: # landscape
size=[11,8.5]
tar=1.4955 # target aspect ratio
else: # portrait
size=[8.5,11]
tar=0.8027 # target aspect ratio
# bounds adjustmets: