-
Notifications
You must be signed in to change notification settings - Fork 2
/
app.py
1489 lines (1307 loc) · 54.5 KB
/
app.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
"""Entry point of the application.
This is the Python script that is run to launch the visualization
application.
Dash expects you to place all your Python callbacks in this file, so
that is what I have done. This file is quite long and unmodularized as
a result. There are some callbacks that are written in JavaScript,
which are referenced in this file, but implemented in
``assets/script.js``.
Dash will execute callbacks in parallel when given the opportunity,
and I have setup my callbacks to take advantage of this for performance
benefits. However, due to what I assume is a limited number of workers,
I have unparallelized some callbacks, which allows certain callbacks to
run faster.
"""
from base64 import b64decode
from json import loads
from os import path, remove, walk
from pathlib import Path
from shutil import copyfile, copytree, make_archive
from subprocess import run
from tempfile import TemporaryDirectory
from uuid import uuid4
import dash
import dash_bootstrap_components as dbc
import dash_core_components as dcc
from dash.dependencies import (ALL, MATCH, ClientsideFunction, Input, Output,
State)
from dash.exceptions import PreventUpdate
from flask_caching import Cache
from data_parser import get_data
from definitions import (ASSETS_DIR, REFERENCE_DATA_DIR, USER_DATA_DIR,
NF_NCOV_VOC_DIR, REFERENCE_SURVEILLANCE_REPORTS_DIR,
USER_SURVEILLANCE_REPORTS_DIR)
from generators import (heatmap_generator, histogram_generator,
legend_generator, table_generator, toast_generator,
toolbar_generator, footer_generator)
# This is the only global variable Dash plays nice with, and it
# contains the visualization that is deployed by this file, when
# ``app`` is served.
app = dash.Dash(
name="VIRUS-MVP",
title="VIRUS-MVP",
assets_folder=ASSETS_DIR,
# We bring in jQuery for some of the JavaScript
# callbacks.
external_scripts=[
"https://code.jquery.com/jquery-2.2.4.min.js",
"https://code.jquery.com/ui/1.11.4/jquery-ui.min.js",
],
# We can use bootstrap CSS.
# https://bit.ly/3tMqY0W for details.
external_stylesheets=[
dbc.themes.COSMO,
"https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.0/font/"
"bootstrap-icons.css"
],
# Callbacks break without this, because they reference
# divs that are not present on initial page load, or
# until ``launch_app`` has finished executing.
suppress_callback_exceptions=True
)
# server instance used for gunicorn deployment
server = app.server
# Cache specifications
cache = Cache(server, config={
"CACHE_TYPE": "filesystem",
"CACHE_DIR": "cache_directory",
# Max number of files app will store before it starts deleting some
"CACHE_THRESHOLD": 200
})
# Cache lasts for a day
TIMEOUT = 86400
# The ``layout`` attribute determines what HTML ``app`` renders when it
# is served. We start with an empty bootstrap container, but it will be
# populated soon after by the ``launch_app`` callback.
app.layout = dbc.Container(
# ``first-launch`` is an in-browser variable, which is only used
# when the page is first loaded. Assigning this variable here
# triggers the ``launch_app`` callback, which populates this
# container with the appropriate content when the page is first
# loaded. More detail on why this is necessary is in the callback
# docstring. ``first-launch-loader`` is also only used when the
# page is first loaded, but ultimately excised from the page.
[
dcc.Loading(None,
id="first-launch-loader",
style={"height": "100%", "width": "100%", "margin": 0}),
dcc.Store("first-launch"),
],
fluid=True,
id="main-container",
className="px-0"
)
@app.callback(
Output("main-container", "children"),
Output("first-launch-loader", "children"),
Input("first-launch", "data")
)
def launch_app(_):
"""Populate empty container in initial layout served by ``app``.
This not only adds HTML, but also several in-browser variables that
are useful for triggering other callbacks.
When the ``first-launch`` in-browser variable is assigned, it
triggers this callback. This callback should not be triggered
again. This callback is only used to serve HTML and in-browser
variables once, when the application is first launched and the main
container is created.
Generating the content below with a callback, instead of in the
global scope, prevents the application from breaking on page
reload. Dash is stateless, so it does not recalculate global
variables on page refreshes after the application is first deployed
to a server. So new data between page reloads may not be displayed
if you do the following in the global scope--which you may be
tempted to do because we are only doing it once!
We also technically return the children for
``first-launch-loader``, but the return value is the same as its
current value (``None``), and since ``first-launch-loader`` is in
``main-container``, its immediately excised from the page after
this callback. The ultimate purpose of this is to replace the blank
loading screen when the app is first loaded.
"""
# Some default vals
get_data_args = {
"show_clade_defining": False,
"hidden_strains": None,
"strain_order": [],
"min_mutation_freq": None,
"max_mutation_freq": None
}
last_data_mtime = max([
max(path.getmtime(root) for root, _, _ in walk(REFERENCE_DATA_DIR)),
max(path.getmtime(root) for root, _, _ in walk(USER_DATA_DIR))
])
data_ = read_data(get_data_args, last_data_mtime)
return [
# Bootstrap collapse containing legend
legend_generator.get_legend_collapse(),
# Bootstrap row containing tools
toolbar_generator.get_toolbar_row(data_),
# Bootstrap row containing toasts
toast_generator.get_toast_row(),
# Bootstrap row containing heatmap
heatmap_generator.get_heatmap_row(data_),
# Bootstrap row containing histogram
histogram_generator.get_histogram_row(data_),
# Bootstrap row containing table
table_generator.get_table_row_div(data_),
# Bootstrap row containing footer
footer_generator.get_footer_row_div(
app.get_asset_url("cidgoh_logo.png")
),
# These are in-browser variables that Dash can treat as Inputs
# and Outputs, in addition to more conventional Dash components
# like HTML divs and Plotly figures. ``get-data-args`` are the
# args used to call ``get_data`` when the underlying data
# structure is needed.
dcc.Store(id="get-data-args", data=get_data_args),
# Last data file modification date. Sometimes data changes, but
# ``get_data`` args do not. Need to rewrite cache.
dcc.Store(id="last-data-mtime", data=last_data_mtime),
# Clientside callbacks use the return val of ``get_data``
# directly.
dcc.Store(id="data", data=data_),
# The following in-browser variables simply exist to help
# modularize the callbacks below.
dcc.Store(id="show-clade-defining",
data=get_data_args["show_clade_defining"]),
dcc.Store(id="new-upload"),
dcc.Store(id="hidden-strains", data=get_data_args["hidden_strains"]),
dcc.Store(id="strain-order", data=get_data_args["strain_order"]),
dcc.Store(id="last-heatmap-cell-clicked"),
dcc.Store(id="last-histogram-point-clicked"),
dcc.Store(id="strain-to-del"),
dcc.Store(id="deleted-strain"),
dcc.Store(id="positions-jumped-to"),
# TODO starting gene should be part of a config file
dcc.Store(id="default-starting-gene", data="S"),
# Used to update certain figures only when necessary
dcc.Store(id="heatmap-x-len", data=len(data_["heatmap_x_nt_pos"])),
dcc.Store(id="heatmap-y-strains",
data=len(data_["heatmap_y_strains"])),
# Used to integrate some JS callbacks. The data values are
# meaningless, we just need outputs to perform all clientside
# functions.
dcc.Store(id="make-select-lineages-modal-checkboxes-draggable"),
dcc.Store(id="make-histogram-rel-pos-bar-dynamic"),
dcc.Store(id="allow-jumps-from-histogram"),
dcc.Store(id="link-heatmap-cells-y-scrolling")
], None
@app.callback(
output=[
Output("get-data-args", "data"),
Output("last-data-mtime", "data"),
Output("data-loading", "data")
],
inputs=[
Input("show-clade-defining", "data"),
Input("new-upload", "data"),
Input("hidden-strains", "data"),
Input("strain-order", "data"),
Input("mutation-freq-slider", "value")
],
prevent_initial_call=True
)
def update_get_data_args(show_clade_defining, new_upload, hidden_strains,
strain_order, mutation_freq_vals):
"""Update ``get-data-args`` variables in dcc.Store.
This is a central callback. Updating ``get-data-args`` triggers a
change to the ``get-data-args`` variable in dcc.Store, which
triggers multiple other callbacks to call ``read_data``, which is a
fn that calls ``get_data`` with ``get-data-args``, and caches the
ret val. This fn calls ``read_data`` first, so it is already cached
before those callbacks need it.
We also update ``last-data-mtime`` here, and ``data-loading``. In
the case of ``data-loading``, we keep the value as ``None``, but
returning it in this fn provides a spinner while this fn is being
run.
:param show_clade_defining: ``update_show_clade-defining`` return
value.
:type show_clade_defining: bool
:param new_upload: ``update_new_upload`` return value
:type new_upload: dict
:param hidden_strains: ``update_hidden_strains`` return value
:type hidden_strains: list[str]
:param strain_order: ``getStrainOrder`` return value from
``script.js``.
:type strain_order: list[str]
:param mutation_freq_vals: Position of handles in mutation freq
slider.
:type mutation_freq_vals: list[int|float]
:param gff3_annotations: ``parse_gff3_file`` return value
:type gff3_annotations: dict
:return: ``get_data`` return value, last mtime across all data
files, and ``data-loading`` children.
:rtype: tuple[dict, float, None]
:raise PreventUpdate: New upload triggered this function, and that
new upload failed.
"""
triggers = [x["prop_id"] for x in dash.callback_context.triggered]
if "new-upload.data" in triggers:
if new_upload["status"] == "error":
raise PreventUpdate
# Do not use the current position of the mutation frequency slider
# if this function was triggered by an input that will modify the
# slider values. We must reset the slider in that case to avoid
# bugs.
use_mutation_freq_vals = "mutation-freq-slider.value" in triggers
use_mutation_freq_vals |= "strain-order.data" in triggers
if use_mutation_freq_vals:
[min_mutation_freq, max_mutation_freq] = mutation_freq_vals
else:
min_mutation_freq, max_mutation_freq = None, None
args = {
"show_clade_defining": show_clade_defining,
"hidden_strains": hidden_strains,
"strain_order": strain_order,
"min_mutation_freq": min_mutation_freq,
"max_mutation_freq": max_mutation_freq
}
# Update ``last-data-mtime`` too
last_data_mtime = max([
max(path.getmtime(root) for root, _, _ in walk(REFERENCE_DATA_DIR)),
max(path.getmtime(root) for root, _, _ in walk(USER_DATA_DIR))
])
# We call ``read_data`` here, so it gets cached. Otherwise, the
# callbacks that call ``read_data`` may do it in parallel--blocking
# multiple processes.
read_data(args, last_data_mtime)
return args, last_data_mtime, None
@cache.memoize(timeout=TIMEOUT)
def read_data(get_data_args, last_data_mtime):
"""Returns and caches return value of ``get_data``.
Why is this function necessary?
Problem: Callbacks need access to the ``get_data`` return value,
but moving it across the network from callback to callback greatly
decreases performance (because it is large).
So what are our options?
Each callback that needs access to the ``get_data`` callback could
call the serverside fn ``get_data`` directly, right? No need to
move it from callback to callback? WRONG. ``get_data`` is an
expensive step. It would be called too often, which again decreases
performance.
Instead, each callback calls this function, which was already
called when ``get-data-args`` was first updated. This fn cached the
return value, so it can now quickly supply it to the callbacks when
necessary. And since this fn is a server-side fn too, it does not
move the cached value over the network.
:param get_data_args: Args for ``get_data``
:type get_data_args: dict
:param last_data_mtime: Last mtime across all data files
:type last_data_mtime: float
"""
ret = get_data(
[REFERENCE_DATA_DIR, USER_DATA_DIR],
show_clade_defining=get_data_args["show_clade_defining"],
hidden_strains=get_data_args["hidden_strains"],
strain_order=get_data_args["strain_order"],
min_mutation_freq=get_data_args["min_mutation_freq"],
max_mutation_freq=get_data_args["max_mutation_freq"]
)
return ret
@app.callback(
Output("show-clade-defining", "data"),
Input("clade-defining-mutations-switch", "value"),
prevent_initial_call=True
)
def update_show_clade_defining(switches_value):
"""Update ``show_clade_defining`` variable in dcc.Store.
This should be set to True when the clade defining mutations switch
is switched on, and False when it is turned off. It is None at
application launch.
:param switches_value: ``[1]`` if the clade defining mutation
switch is switched on, and ``[]`` if it is not.
:type switches_value: list
:return: True if clade defining mutations switch is switched on
:rtype: bool
"""
return len(switches_value) > 0
@app.callback(
Output("new-upload", "data"),
Output("upload-loading", "children"),
Output("upload-file", "contents"),
Output("upload-file", "filename"),
Input("upload-file", "contents"),
Input("upload-file", "filename"),
State("get-data-args", "data"),
State("last-data-mtime", "data"),
prevent_initial_call=True
)
def update_new_upload(file_contents, filename, get_data_args, last_data_mtime):
"""Update ``new_upload`` variable in dcc.Store.
If a valid file is uploaded, it will be written to ``user_data``.
But regardless of whether a valid file is uploaded, this function
will return a dict describing the name of the file the user
attempted to upload, status of upload, and name of uploaded strain.
We also re-render the uploading btn after the file is processed. We
do this because the btn is in a loading container, so a spinner
will display in place of the button while the file is being
processed. Which is useful feedback, and keeps uploads linear at a
single endpoint.
We also write the surveillance reports to disk.
TODO eventually write to database instead of disk
:param file_contents: Contents of uploaded file, formatted by Dash
into a base64 string.
:type file_contents: str
:param filename: Name of uploaded file
:type filename: str
:param get_data_args: Args for ``get_data``
:type get_data_args: dict
:param last_data_mtime: Last mtime across all data files
:type last_data_mtime: float
:return: Dictionary describing upload attempt
:rtype: dict
"""
# Current ``get_data`` return val
old_data = read_data(get_data_args, last_data_mtime)
posix_path = Path(filename)
sample_name = posix_path.stem
# https://stackoverflow.com/a/35188296
ext = "".join(posix_path.suffixes)[1:]
# TODO more thorough validation, maybe once we finalize data
# standards.
accepted_exts = {"VCF", "fasta", "fa", "fna", "fa.gz", "fna.gz", "fasta.gz"}
if ext not in accepted_exts:
status = "error"
msg = "Accepted file extensions: %s" % accepted_exts
elif sample_name in old_data["all_strains"]:
status = "error"
msg = "Filename must not conflict with existing variant."
else:
# Dash splits MIME type and the actual str with a comma
_, base64_str = file_contents.split(",")
# Run pipeline, but output contents into temporary dir. Then
# copy appropriate output file to relevant dir.
with TemporaryDirectory(dir=NF_NCOV_VOC_DIR) as dir_name:
user_file = path.join(dir_name, filename)
rand_prefix = "u" + str(uuid4())
with open(user_file, "w") as fp:
fp.write(b64decode(base64_str).decode("utf-8"))
run(["nextflow", "run", "main.nf", "-profile", "docker",
"--prefix", rand_prefix, "--mode", "user",
"--viral_aligner", "minimap2", "--skip_postprocessing", "true",
"--skip_posting", "true", "skip_harmonize", "true",
"--seq", user_file, "--outdir", dir_name],
cwd=NF_NCOV_VOC_DIR)
results_path = path.join(dir_name, rand_prefix, "VARIANTANNOTATION")
gvf_file = path.join(results_path, "%s_annotated.gvf" % sample_name)
copyfile(gvf_file, path.join(USER_DATA_DIR, sample_name + ".gvf"))
status = "ok"
msg = "%s uploaded successfully." % filename
new_upload_data = {"filename": filename,
"msg": msg,
"status": status,
"strain": sample_name}
upload_component = toolbar_generator.get_file_upload_component()
return new_upload_data, upload_component, "", ""
@app.callback(
Output("download-file-data", "data"),
Input("download-file-btn", "n_clicks"),
prevent_initial_call=True
)
def trigger_download(_):
"""Send download file when user clicks download btn.
This is a zip object of surveillance reports.
:param _: Unused input variable that monitors when download btn is
clicked.
:return: Fires dash function that triggers file download
"""
with TemporaryDirectory() as dir_name:
reports_path = path.join(dir_name, "surveillance_reports")
copytree(REFERENCE_SURVEILLANCE_REPORTS_DIR,
path.join(reports_path, "reference_surveillance_reports"))
copytree(USER_SURVEILLANCE_REPORTS_DIR,
path.join(reports_path, "user_surveillance_reports"))
make_archive(reports_path, "zip", reports_path)
return dcc.send_file(reports_path + ".zip")
@app.callback(
Output("toast-col", "children"),
Input("new-upload", "data"),
Input("mutation-freq-slider", "marks"),
Input("positions-jumped-to", "data"),
prevent_initial_call=True
)
def toggle_toast(new_upload, _, positions_jumped_to):
"""Update ``toast-col`` div.
This function shows appropriate toasts when there was following a
user upload, re-rendering of mutation frequency vals, or the
heatmap is automatically scrolled to a specific mutation.
:param new_upload: ``update_new_upload`` return value
:type new_upload: dict
:param _: Unused input variable that allows re-rendering of the
mutation frequency slider to trigger this function.
:param positions_jumped_to:
``jumpToHeatmapPosAfterSelectingMutationName`` return val.
:type positions_jumped_to: dict
:return: Appropriate Dash Bootstrap Components toast for situation
:rtype: dbc.Toast
"""
triggers = [x["prop_id"] for x in dash.callback_context.triggered]
if "new-upload.data" in triggers:
if new_upload["status"] == "ok":
return toast_generator.get_toast(
new_upload["msg"],
"Success",
"success",
5000
)
if new_upload["status"] == "error":
return toast_generator.get_toast(
new_upload["msg"],
"Error",
"danger",
5000
)
elif "mutation-freq-slider.marks" in triggers:
return toast_generator.get_toast(
"Mutation frequency slider vals set to min and max",
"Info",
"info",
5000
)
elif "positions-jumped-to.data" in triggers:
msg = "Jumped to strain %s at nucleotide position %s"
msg %= (positions_jumped_to["strain"], positions_jumped_to["nt_pos"])
return toast_generator.get_toast(
msg,
"Info",
"info",
10000
)
@app.callback(
Output("hidden-strains", "data"),
Input("select-lineages-ok-btn", "n_clicks"),
Input("deleted-strain", "data"),
State({"type": "select-lineages-modal-checkbox", "index": ALL}, "id"),
State({"type": "select-lineages-modal-checkbox", "index": ALL}, "checked"),
State("get-data-args", "data"),
State("last-data-mtime", "data"),
prevent_initial_call=True
)
def update_hidden_strains(_, deleted_strain, checkbox_ids, checkbox_vals,
get_data_args, last_data_mtime):
"""Update ``hidden-strains`` variable in dcc.Store.
When the OK button is clicked in the select lineages modal, the
unchecked boxes are returned as the new ``hidden-strains`` value.
We also update ``hidden-strains`` if the user deleted a strain.
:param _: Otherwise useless input only needed to alert us when the
ok button in the select lineages modal was clicked.
:param deleted_strain: Name of strain user just deleted
:type deleted_strain: str
:param checkbox_ids: List of ids corresponding to checkboxes
:type checkbox_ids: list[dict]
:param checkbox_vals: List of booleans corresponding to checkboxes
indicating whether they are checked or not.
:type checkbox_vals: list[bool]
:param get_data_args: Args for ``get_data``
:type get_data_args: dict
:param last_data_mtime: Last mtime across all data files
:type last_data_mtime: float
:return: List of strains that should not be displayed by the
heatmap or table.
:rtype: list[str]
:raise PreventUpdate: Hidden strains did not change, or the user
chose to hide all strains.
"""
# Current ``get_data`` return val
data = read_data(get_data_args, last_data_mtime)
old_hidden_strains = data["hidden_strains"]
trigger = dash.callback_context.triggered[0]["prop_id"]
if trigger == "deleted-strain.data":
if deleted_strain in old_hidden_strains:
old_hidden_strains.remove(deleted_strain)
return old_hidden_strains
checkbox_strains = [id["index"] for id in checkbox_ids]
hidden_strains_vals_zip_obj = \
filter(lambda x: not x[1], zip(checkbox_strains, checkbox_vals))
hidden_strains = [strain for (strain, _) in hidden_strains_vals_zip_obj]
old_hidden_strains = data["hidden_strains"]
no_change = hidden_strains == old_hidden_strains
all_hidden = checkbox_strains == hidden_strains
if no_change or all_hidden:
raise PreventUpdate
return hidden_strains
@app.callback(
Output("select-lineages-modal-body", "children"),
Input("get-data-args", "data"),
State("last-data-mtime", "data"),
prevent_initial_call=True
)
def update_select_lineages_modal_body(get_data_args, last_data_mtime):
"""Populate select lineages modal body.
This is triggered behind the scenes without opening the modal, and
whenever the data is updated.
:param get_data_args: Args for ``get_data``
:type get_data_args: dict
:param last_data_mtime: Last mtime across all data files
:type last_data_mtime: float
:return: Content representing the select lineages modal body
:rtype: list[dbc.FormGroup]
"""
data = read_data(get_data_args, last_data_mtime)
return toolbar_generator.get_select_lineages_modal_body(data)
@app.callback(
Output("select-lineages-modal", "is_open"),
Output("select-lineages-modal-loading", "children"),
Input("open-select-lineages-modal-btn", "n_clicks"),
Input("select-lineages-ok-btn", "n_clicks"),
Input("select-lineages-cancel-btn", "n_clicks"),
Input("deleted-strain", "data"),
prevent_initial_call=True
)
def toggle_select_lineages_modal(_, __, ___, ____):
"""Open or close select lineages modal.
This is a little slow to open, so we return
``select-lineages-modal-loading`` to add a spinner.
:param _: Select lineages button in toolbar was clicked
:param __: OK button in select lineages modal was clicked
:param ___: Cancel button in select lineages modal was clicked
:param ____: OK button in confirm strain deletion modal was clicked
:return: Boolean representing whether the select lineages modal is
open or closed, and ``select-lineages-modal-loading`` children.
:rtype: (bool, bool)
"""
ctx = dash.callback_context
triggered_prop_id = ctx.triggered[0]["prop_id"]
# We only open the modal when the select lineages modal btn in the
# toolbar is clicked.
if triggered_prop_id == "open-select-lineages-modal-btn.n_clicks":
return True, None
else:
return False, None
@app.callback(
Output({"type": "select-lineages-modal-checklist", "index": MATCH},
"children"),
Input({"type": "select-lineages-modal-all-btn", "index": MATCH},
"n_clicks"),
Input({"type": "select-lineages-modal-none-btn", "index": MATCH},
"n_clicks"),
State({"type": "select-lineages-modal-checklist", "index": MATCH},
"children"),
prevent_initial_call=True
)
def toggle_all_strains_in_select_all_lineages_modal(_, __, checkbox_rows):
"""Toggle checkboxes after user clicks "all" or "none" modal btns.
Only the relevant checkboxes are toggled, specific to a directory,
depending on which "all" or "none" btn the user clicked.
:param: _: "all" btn in select lineages modal was clicked.
:param: __: "none" btn in select lineages modal was clicked.
:param checkbox_rows: List of rows containing checkboxes in each
dir subsection of select lineages modal.
:type: list
:return: ``checkbox_rows``, but with appropriately modified checked
vals for checkboxes.
"""
ctx = dash.callback_context
triggered_prop_id = ctx.triggered[0]["prop_id"]
triggered_prop_id_type = loads(triggered_prop_id.split(".")[0])["type"]
ret = checkbox_rows
# TODO the way we edit the children is hackey and could break in
# the future. But we're in a bit of a time crunch atm.
if triggered_prop_id_type == "select-lineages-modal-all-btn":
for i in range(len(ret)):
ret[i]["props"]["children"][0]["props"]\
["children"]["props"]["checked"] = True
else:
for i in range(len(ret)):
ret[i]["props"]["children"][0]["props"]\
["children"]["props"]["checked"] = False
return ret
@app.callback(
Output("strain-to-del", "data"),
Input({"type": "checkbox-del-btn", "index": ALL}, "n_clicks"),
prevent_initial_call=True
)
def update_strain_to_del(n_clicks):
"""Update ``strain-to-del`` var.
This happens after a user clicks a delete btn for a strain. We do
not immediately delete the strain, but update a var that allows
user confirmation.
:param n_clicks: List of times each delete btn inside the select
lineages modal was clicked.
:type n_clicks: list[int]
"""
# Select lineages modal was just opened
if all(e is None for e in n_clicks):
raise PreventUpdate
ctx = dash.callback_context
triggered_prop_id = ctx.triggered[0]["prop_id"]
strain_to_del = loads(triggered_prop_id.split(".")[0])["index"]
return strain_to_del
@app.callback(
Output("confirm-strain-del-modal", "is_open"),
Output("confirm-strain-del-modal-body", "children"),
Input("strain-to-del", "data"),
Input("confirm-strain-del-modal-cancel-btn", "n_clicks"),
Input("deleted-strain", "data"),
prevent_initial_call=True
)
def toggle_confirm_strain_del_modal(strain_to_del, _, __):
"""Open or close confirm strain deletion modal.
This modal opens when a user clicks a delete btn in the select
lineages modal. It closes when the user clicks the cancel btn in
the confirm strain deletion modal. And it closes when the user
update the ``deleted_strain`` var.
:param strain_to_del: Strain corresponding to del btn user just
clicked.
:type strain_to_del: str
:param _: User clicked cancel btn in confirm strain del modal
:param __: User updated ``deleted-strain`` var
"""
ctx = dash.callback_context.triggered[0]["prop_id"]
if ctx == "strain-to-del.data":
msg = "Delete %s?" % strain_to_del
return True, msg
elif ctx == "deleted-strain.data":
return False, None
else:
return False, None
@app.callback(
Output("jump-to-modal", "is_open"),
Output("jump-to-modal-dropdown-search", "options"),
Input("jump-to-btn", "n_clicks"),
Input("jump-to-modal-ok-btn", "n_clicks"),
Input("jump-to-modal-cancel-btn", "n_clicks"),
State("get-data-args", "data"),
State("last-data-mtime", "data"),
prevent_initial_call=True
)
def toggle_jump_to_modal(_, __, ___, get_data_args, last_data_mtime):
"""Open or close modal for jumping to mutations.
This modal opens when a user clicks the toolbar btn for jumping to
mutations. It closes when the user clicks the cancel or ok btn in
the modal.
This fn also populates the dropdown content when the modal is
opened.
:param _: User clicked btn in toolbar for opening modal
:param __: User clicked ok btn in modal
:param ___: User clicked cancel btn in modal
:return: Whether modal is open, and the dropdown content if it is
:rtype: (bool, list)
"""
ctx = dash.callback_context.triggered[0]["prop_id"]
if ctx == "jump-to-btn.n_clicks":
# Current ``get_data`` return val
data = read_data(get_data_args, last_data_mtime)
return True, data["jump_to_dropdown_search_options"]
else:
return False, []
@app.callback(
Output("deleted-strain", "data"),
Input("confirm-strain-del-modal-ok-btn", "n_clicks"),
State("strain-to-del", "data"),
prevent_initial_call=True
)
def update_deleted_strain(_, strain_to_del):
"""Update ``deleted-strain`` var.
This happens after a user clicks the OK btn in the confirm strain
deletion modal.
We also delete the files associated with the strain at this step.
:param _: User clicked the OK btn
:param strain_to_del: Strain corresponding to del btn user clicked
:type strain_to_del: str
"""
remove(path.join(USER_DATA_DIR, strain_to_del + ".gvf"))
return strain_to_del
@app.callback(
Output("mutation-freq-slider-col", "children"),
Input("get-data-args", "data"),
State("mutation-freq-slider", "marks"),
State("last-data-mtime", "data"),
prevent_initial_call=True
)
def update_mutation_freq_slider(get_data_args, old_slider_marks,
last_data_mtime):
"""Update mutation frequency slider div.
If the ``data`` dcc variable is updated, this function will
re-render the slider if the new ``data`` variable has a different
set of mutation frequencies.
:param get_data_args: Args for ``get_data``
:type get_data_args: dict
:param old_slider_marks: ``marks`` property of the current
mutation frequency slider div.
:type old_slider_marks: dict
:param last_data_mtime: Last mtime across all data files
:type last_data_mtime: float
:return: New mutation frequency slider div, if one is needed
:rtype: dcc.RangeSlider
:raise PreventUpdate: Number of mutation frequencies in ``data`` is
different than the number of mutation frequencies in the
current slider.
"""
# Current ``get_data`` return val
data = read_data(get_data_args, last_data_mtime)
# This is very hackey, but also very fast. I do not think this will
# currently break anything.
new_slider_marks = data["mutation_freq_slider_vals"]
if len(new_slider_marks) == len(old_slider_marks):
raise PreventUpdate
return toolbar_generator.get_mutation_freq_slider(data)
@app.callback(
Output("legend-collapse", "is_open"),
Input("toggle-legend-btn", "n_clicks"),
State("legend-collapse", "is_open"),
prevent_initial_call=True
)
def toggle_legend_collapse(_, is_open):
"""Open or close legend view.
:param _: Toggle legend btn was clicked
:param is_open: Current visibility of legend
:return: New visbility for legend; opposite of ``is_open``
:rtype: bool
"""
return not is_open
@app.callback(
Output("heatmap-x-len", "data"),
Input("get-data-args", "data"),
State("heatmap-x-len", "data"),
State("last-data-mtime", "data"),
prevent_initial_call=True
)
def route_data_heatmap_x_update(get_data_args, old_heatmap_x_len,
last_data_mtime):
"""Update ``heatmap-x-len`` dcc variable when needed.
This serves as a useful trigger for figs that only need to be
updated when heatmap x coords change. We use the length of
data["heatmap_x_nt_pos"] because it is faster than comparing the
entire list, and appropriately alerts us when
data["heatmap_x_nt_pos"] changed.
:param get_data_args: Args for ``get_data``
:type get_data_args: dict
:param old_heatmap_x_len: ``heatmap-x-len.data`` value
:type old_heatmap_x_len: dict
:param last_data_mtime: Last mtime across all data files
:type last_data_mtime: float
:return: New len of data["heatmap_x_nt_pos"]
:rtype: int
:raise PreventUpdate: If data["heatmap_x_nt_pos"] len did not
change.
"""
# Current ``get_data`` return val
data = read_data(get_data_args, last_data_mtime)
if old_heatmap_x_len == len(data["heatmap_x_nt_pos"]):
raise PreventUpdate
return len(data["heatmap_x_nt_pos"])
@app.callback(
Output("heatmap-y-strains", "data"),
Input("get-data-args", "data"),
State("heatmap-y-strains", "data"),
State("last-data-mtime", "data"),
prevent_initial_call=True
)
def route_data_heatmap_y_strains_update(get_data_args, old_heatmap_y_strains,
last_data_mtime):
"""Update ``heatmap-y-strains`` dcc variable when needed.
This serves as a useful trigger for figs that only need to be
updated when heatmap strains change.
:param get_data_args: Args for ``get_data``
:type get_data_args: dict
:param old_heatmap_y_strains: ``heatmap-y-strains.data`` value
:type old_heatmap_y_strains: dict
:param last_data_mtime: Last mtime across all data files
:type last_data_mtime: float
:return: New len of data["heatmap_y_strains"]
:rtype: int
:raise PreventUpdate: If data["heatmap_y_strains"] len did not
change.
"""
# Current ``get_data`` return val
data = read_data(get_data_args, last_data_mtime)
if old_heatmap_y_strains == data["heatmap_y_strains"]:
raise PreventUpdate
return data["heatmap_y_strains"]
@app.callback(
Output("heatmap-strains-axis-fig", "figure"),
Output("heatmap-strains-axis-fig", "style"),
Output("heatmap-strains-axis-inner-container", "style"),
Output("heatmap-strains-axis-outer-container", "style"),
Input("heatmap-y-strains", "data"),
State("get-data-args", "data"),
State("last-data-mtime", "data"),
prevent_initial_call=True
)
def update_heatmap_strains_axis_fig(_, get_data_args, last_data_mtime):
"""Update heatmap strains axis fig and containers.
We need to update style because attributes may change due to
uploaded strains.
:param _: Heatmap strains updated
:param get_data_args: Args for ``get_data``
:type get_data_args: dict
:param last_data_mtime: Last mtime across all data files
:type last_data_mtime: float
:return: New heatmap strains axis fig and style
:rtype: (plotly.graph_objects.Figure, dict)
"""
# Current ``get_data`` return val
data = read_data(get_data_args, last_data_mtime)
strain_axis_fig = heatmap_generator.get_heatmap_strains_axis_fig(data)
strain_axis_style = \
{"height": data["heatmap_cells_fig_height"],
"width": "101%",
"marginBottom": -data["heatmap_cells_container_height"]}
inner_container_style = {
"height": "100%",
"overflowY": "scroll",
"marginBottom":
-data["heatmap_cells_container_height"]-50,
"paddingBottom":
data["heatmap_cells_container_height"]+50
}
outer_container_style = {
"height": data["heatmap_cells_container_height"],
"overflow": "hidden"
}
return (strain_axis_fig, strain_axis_style, inner_container_style,
outer_container_style)
@app.callback(
Output("heatmap-sample-size-axis-fig", "figure"),
Output("heatmap-sample-size-axis-fig", "style"),
Output("heatmap-sample-size-axis-inner-container", "style"),
Output("heatmap-sample-size-axis-outer-container", "style"),
Input("heatmap-y-strains", "data"),
State("get-data-args", "data"),
State("last-data-mtime", "data"),
prevent_initial_call=True
)
def update_heatmap_sample_size_axis_fig(_, get_data_args, last_data_mtime):
"""Update heatmap sample size axis fig and containers.
We need to update style because attributes may change due to
uploaded strains.
:param _: Heatmap strains updated
:param get_data_args: Args for ``get_data``
:type get_data_args: dict
:param last_data_mtime: Last mtime across all data files
:type last_data_mtime: float
:return: New heatmap sample size axis fig and style