-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathutils.py
More file actions
3695 lines (3350 loc) · 165 KB
/
Copy pathutils.py
File metadata and controls
3695 lines (3350 loc) · 165 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
"""Shared analysis and plotting utilities for the LABEL-seq MAPK manuscript.
This module is the single place where the project's *conventions* live — score
columns, class definitions, thresholds, colour palettes, protein labels — so
that the figure notebooks (``Annotations.ipynb``, ``DN.ipynb``, ``HSP90i.ipynb``)
contain only the logic specific to each panel.
Design rules, all of them learned the hard way (see
``docs/hsp90_metric_definitions.md`` and ``docs/known_bugs_and_gotchas.md``):
1. **Every threshold is computed per (library, assay, assay_treatment).**
``classification_2.5pct`` is computed at that granularity in the scoring
pipeline, so anything derived from a synonymous-WT percentile must match it.
Pooling libraries would mix ARAF cterm (DMSO control) with ARAF nterm
(No_treatment control) — see gotcha G7.
2. **Two scales, never mixed.** *Buffering* is wild-type-relative (built on
``average score``, which re-anchors WT to 1.0 within each condition, so the
global WT shift cancels). *Dependence* is absolute (built on
``intercept_0_standard-adjusted score``, which retains it). Function names
here always say which scale they are on.
3. **MET is displayed as "HGFR"** in every figure. Use :func:`protein_label`
for any user-visible protein string; never print the raw key.
**Rendering, conventions, and the annotation plumbing.** Most of this module is
plot builders that take already-computed data and turn it into a figure, plus
the shared conventions (palettes, protein order and display names, score column
names, thresholds) and the style/save helpers.
Since 2026-08-14 it also carries the *input paths* and the small annotation
helpers that attach a file to a scored table — :func:`annotation_sources`,
:func:`input_manifest`, :func:`add_structure_annotations`. They live here so
that a notebook can declare and check every file it reads in one place instead
of leaving paths buried in scripts. The rule they do **not** break: no
classification and no statistics live here. Anything that decides what a variant
*is* — a threshold, a class, a test — stays in the notebook, next to the result
it produces.
Import at the top of a notebook::
import utils
utils.apply_style()
"""
from __future__ import annotations
import os
import subprocess
from pathlib import Path
from typing import Iterable, Mapping, Sequence
import matplotlib.colors as mcolors
import matplotlib.font_manager as fm
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from matplotlib.axes import Axes
from matplotlib.figure import Figure
__all__ = ["PROJECT_ROOT", "DN_ORDER", "HSP90_ORDER", "PROTEIN_KEYS",
"DN_ELIGIBLE", "INHIBITORY_PROTEINS", "HSP90I_PROTEINS",
"BASELINE_TREATMENTS", "PROTEIN_ALTERING_TYPES", "SCORE_WT_REL",
"SCORE_ABS", "SCORE_ABS_REPS", "BUFFERING_COLORS", "BUFFERING_ORDER",
"BUFFERING_CMAPS", "ACTIVITY_COLORS", "ACTIVITY_ORDER", "DB_COLORS",
"VARIANT_TYPE_COLORS", "VARIANT_TYPE_ORDER", "PROTEIN_SPHERE_COLOR",
"STRUCT_CARTOON_COLOR", "STRUCT_PARTNER_COLORS", "ACTIVITY_GROUP_ORDER",
"ACTIVITY_GROUP_COLOR", "MIN_N_PER_PROTEIN", "ONTOLOGY_BUCKET_ORDER",
"ONTOLOGY_BUCKET_STYLE", "ONTOLOGY_BUCKET_LABELS",
"ONTOLOGY_LEGEND_ROWS", "apply_style", "save_figure", "protein_label",
"protein_labels", "stars_4tier", "stars_4tier_ns",
"fixed_pitch_bar_axes", "plot_stacked_vbar", "plot_paired_score_violins",
"plot_density_panel", "plot_beta_heatmap", "BUFFERING_STACK_ORDER",
"plot_forest", "plot_stacked_hbar", "plot_bubble_grid", "plot_heatmap",
"plot_violin_panel", "plot_donut", "plot_univariate_profiles",
"plot_dn_positions", "plot_activity_histogram", "plot_dn_summary_table",
"plot_structure", "plot_sphere_legend", "PYMOL_BIN",
"plot_class_depletion_heatmap", "plot_class_depletion_pooled",
"DEPLETION_DB_STYLE", "DEPLETION_DB_OFFSET",
]
# ---------------------------------------------------------------------------
# Paths
# ---------------------------------------------------------------------------
PROJECT_ROOT = Path(__file__).resolve().parent
DATA = PROJECT_ROOT / "data"
OUTPUT = PROJECT_ROOT / "output"
#: Stage 1: scores and canonical identity, from the barcode counts. Written by
#: Scoring.ipynb (or scripts/score_cell.py + finalize_scores.py). No annotations.
SCORES_RECOVERED = OUTPUT / "scoring" / "scores_masterframe_recovered.tsv"
#: Stage 2 output and the base layer for the figures: our scores with every
#: annotation rebuilt from primary sources by scripts/reannotate_scores.py
#: (the annotation engine over the 22 annotation files, plus
#: scripts/compute_structure_annotations.py over the AlphaFold models).
SCORES_REANNOTATED = OUTPUT / "scoring" / "scores_reannotated.tsv"
#: Written by Annotations.ipynb: the combined annotation set the figures use.
ANNOTATED_COMBINED = OUTPUT / "annotated_combined.tsv"
#: Barcode-level scores for both no-variant controls (`empty_vector_std` and
#: `NoVar_std`), written by Scoring.ipynb Step 11. The raw material the DN
#: threshold is derived from, kept so the derivation can be shown rather than
#: asserted.
CONTROL_BARCODE_SCORES = OUTPUT / "scoring" / "control_barcode_scores.tsv"
#: DN thresholds recomputed from those barcodes: the 2.5th percentile of 200
#: draws of a 10-barcode mean, i.e. the distribution of a *variant-like* score
#: under an empty construct. Reproduces the delivered cutoffs to a median ratio
#: of 0.99, which is what identifies it as the method that produced them.
DN_THRESHOLDS = OUTPUT / "scoring" / "dn_thresholds_recomputed.tsv"
#: The delivered cutoffs. No longer an input — retained only for the agreement
#: check in Scoring.ipynb Step 11.
EV_CUTOFFS = DATA / "dn_cutoffs_empty_vector.tsv"
INTERACTIONS_AUGMENTED = OUTPUT / "dn" / "interactions_curated_augmented.tsv"
POPULATION_HGVSP = OUTPUT / "population_data_hgvsp.tsv"
#: Per-residue structural annotations from the raw AlphaFold models, written by
#: ``scripts/compute_structure_annotations.py``: pLDDT, the structure's own
#: residue identity, and intramolecular domain-domain contacts.
STRUCTURE_ANNOTATIONS = OUTPUT / "structure_annotations.tsv"
#: The AlphaFold v4 monomer models the structural annotations are computed from,
#: and which the DSSP outputs (hence every RSA in the project) came from.
AF_STRUCTURES = (PROJECT_ROOT / "data" / "inputs" / "annotations" / "AF_MAPK_structures")
#: Quantitative predictors and contact sets, each produced by its own script.
SPURS_DDG = PROJECT_ROOT / "data" / "inputs" / "spurs_ddg_all_proteins.tsv"
PHYLOP_PER_POSITION = OUTPUT / "dn" / "phylop_per_position.tsv"
KINASE_JSD = OUTPUT / "hsp90" / "kinase_jsd_per_position.tsv"
TRANSFERRED_CONTACTS = OUTPUT / "hsp90" / "transferred_contacts.tsv"
KINASE_MOTIFS = OUTPUT / "hsp90" / "kinase_motif_assignments.tsv"
GENIE_JOINED = OUTPUT / "clinical" / "activity_vs_genie_joined.tsv"
REFERENCE_FASTA = DATA / "reference_protein_sequences.fasta"
PROTEIN_ACCESSIONS = PROJECT_ROOT / "config" / "protein_accessions.yaml"
def annotation_sources() -> dict:
"""The primary annotation files, resolved from ``config/paths.yaml``.
The 22 downloaded/derived inputs the annotation engine reads — DSSP outputs,
NCBI conserved domains, PhosphoSitePlus, ClinVar, AlphaMissense, CysDB, the
published DMS tables, the kinase alignment. Exposed so a notebook can print
and check every input it depends on rather than trusting a path buried in a
script.
"""
import sys
sys.path.insert(0, str(PROJECT_ROOT / "src"))
from labelseq_mapk.config import load_config
return load_config(PROJECT_ROOT / "config")["paths"]["annotations"]
def input_manifest(extra: dict | None = None) -> "pd.DataFrame":
"""Every input file this pipeline reads, with a size and an existence check.
Printed at the top of a notebook so that a missing or truncated input is
caught before it becomes a puzzling number in a figure.
"""
named = {
"scores (stage 1)": SCORES_RECOVERED,
"scores re-annotated (stage 2a)": SCORES_REANNOTATED,
"structure annotations": STRUCTURE_ANNOTATIONS,
"AlphaFold models": AF_STRUCTURES,
"control barcode scores": CONTROL_BARCODE_SCORES,
"DN thresholds (recomputed)": DN_THRESHOLDS,
"curated interactions": INTERACTIONS_AUGMENTED,
"HSP90/CDC37 contacts": TRANSFERRED_CONTACTS,
"kinase motifs": KINASE_MOTIFS,
"phyloP": PHYLOP_PER_POSITION,
"kinase JSD": KINASE_JSD,
"SPURS ddG": SPURS_DDG,
"population (gnomAD/AoU)": POPULATION_HGVSP,
"GENIE": GENIE_JOINED,
"reference sequences": REFERENCE_FASTA,
"protein accessions": PROTEIN_ACCESSIONS,
}
named.update({f"annotation source: {k}": Path(v)
for k, v in annotation_sources().items()})
if extra:
named.update({k: Path(v) for k, v in extra.items()})
rows = []
for label, p in named.items():
p = Path(p)
if p.is_dir():
n = len(list(p.glob("*")))
size = f"{n} files"
elif p.exists():
size = f"{p.stat().st_size / 1e6:,.1f} MB"
else:
size = "—"
rows.append({"input": label, "exists": p.exists(), "size": size,
"path": str(p)})
return pd.DataFrame(rows)
def add_structure_annotations(df: "pd.DataFrame",
path: "Path | None" = None) -> "pd.DataFrame":
"""Merge the AlphaFold-derived per-residue columns onto a scored table.
Keyed on ``(protein, Position)``. Adds ``plddt``, ``pdb_aa``,
``inter_domain_contacts``, ``inter_domain_contacts_all_atom``,
``inter_domain_partners``, and derives ``pdb_aa_mismatch`` — the structure's
residue disagreeing with our wild-type call, which means the model and our
numbering describe different isoforms.
"""
path = Path(path or STRUCTURE_ANNOTATIONS)
if not path.exists():
raise FileNotFoundError(
f"{path} not found — run scripts/compute_structure_annotations.py")
# 4 A all-atom only -- the older `inter_domain_contacts` cutoff was
# superseded on 2026-08-10 and is no longer merged onto scores.
cols = ["plddt", "pdb_aa",
"inter_domain_contacts_all_atom", "inter_domain_partners"]
st = (pd.read_csv(path, sep="\t")[["protein", "position"] + cols]
.drop_duplicates(["protein", "position"]))
out = df.copy()
out["_pos"] = pd.to_numeric(out["Position"], errors="coerce")
out = out.merge(st.rename(columns={"position": "_pos"}),
on=["protein", "_pos"], how="left").drop(columns="_pos")
out["pdb_aa_mismatch"] = (out["pdb_aa"].notna()
& (out["pdb_aa"] != out["Wild Type Residue"]))
for c in ("inter_domain_contacts_all_atom",):
out[c] = out[c].fillna(False).astype(bool)
return out
# ---------------------------------------------------------------------------
# Score columns
# ---------------------------------------------------------------------------
#: WT-normalised score. WT == 1.0 **within each condition**, so differences on
#: this column are wild-type-relative (the *buffering* scale).
SCORE_WT_REL = "average score"
#: Standard-curve-corrected score. Retains the global WT shift between
#: conditions, so differences on this column are absolute (the *dependence*
#: scale). Compressed by the zero-intercept fit — fine for ranking within a
#: library, treat cross-protein absolute values with care.
SCORE_ABS = "intercept_0_standard-adjusted score"
#: Per-replicate companions of SCORE_ABS. New in the 080126 delivery; these are
#: what make a per-variant significance test on the absolute scale possible.
SCORE_ABS_REPS = [f"intercept_0_std_adj_score_{j}" for j in (1, 2, 3)]
# ---------------------------------------------------------------------------
# Protein sets and display names
# ---------------------------------------------------------------------------
#: Figure-3 (dominant-negative) protein order: RAFs, RTKs, GTPases, GEFs,
#: phosphatase, then the scaffolds/adaptor. Shared by the DN panels so their
#: columns line up when stacked.
#:
#: NOT a general-purpose protein list — it contains no MEK, because MEK is not
#: DN-eligible. Ordering a figure-4 panel through it drops MEK1 and MEK2
#: silently and sorts KSR2 to the end; use :data:`HSP90_ORDER` there.
DN_ORDER = [
"araf", "braf", "craf",
"egfr", "erbb2", "met", "ret",
"kras", "mras",
"sos1", "sos2",
"shp2",
"grb2", "ksr1", "ksr2",
]
#: Figure-4 (HSP90) protein order: the nine kinase-domain-bearing proteins
#: profiled under HSP90 inhibition, grouped by how much their WILD TYPE depends
#: on HSP90 — high (CRAF, ARAF, RET), moderate (KSR2, EGFR, HGFR), low (BRAF,
#: MEK2, MEK1). This is the order the violin panels use, so every figure-4 panel
#: that resolves by protein reads in the same sequence.
#:
#: SOS2 is not here: it has no kinase domain, and its response is inverted
#: (wild-type SOS2 rises under HSP90 inhibition). The all-variant panels append
#: it as its own tier.
HSP90_ORDER = [
"craf", "araf", "ret",
"ksr2", "egfr", "met",
"braf", "mek2", "mek1",
]
#: Every protein key either order knows about — for "is this string a protein?"
#: checks, where ordering is irrelevant.
PROTEIN_KEYS = DN_ORDER + [p for p in HSP90_ORDER if p not in DN_ORDER]
#: The 12 proteins whose wild-type overexpression raises pathway activity, so a
#: variant scoring below the empty-vector baseline is interpretable as dominant
#: negative. Excludes the inhibitory set below.
DN_ELIGIBLE = [
"araf", "braf", "craf", "egfr", "erbb2", "kras",
"met", "mras", "ret", "shp2", "sos1", "sos2",
]
#: Overexpressing these *lowers* pathway activity, so "below empty vector" is
#: the expected wild-type phenotype, not dominant negativity. Matches
#: ``config/scoring.yaml: inhibitory_proteins``.
INHIBITORY_PROTEINS = {"grb2", "ksr1", "ksr2", "mek1", "mek2"}
#: The 9 kinases with paired control/HSP90i abundance data.
#: Every protein profiled under HSP90 inhibition — ten, including SOS2.
#:
#: SOS2 belongs here (8,397 abundance measurements) even though it is absent
#: from :data:`HSP90_ORDER`: it has no kinase domain, so it cannot appear in the
#: kinase-domain panels, and its response is inverted. Omitting it from this
#: list silently dropped its wild type from the dependence panel.
HSP90I_PROTEINS = ["araf", "braf", "craf", "egfr", "ksr2", "mek1", "mek2",
"met", "ret", "sos2"]
#: Unperturbed activity conditions. DN is defined only here: SerumStarve
#: collapses EGFR-WT onto the empty-vector baseline, and CIAR drives the pathway
#: independently of variant identity, so neither reports variant function
#: against a meaningful no-kinase floor.
BASELINE_TREATMENTS = {"No_treatment", "DMSO"}
#: Variant types that change the protein product. DN is restricted to these:
#: synonymous and the BRAF spike-in standards cannot be dominant negative.
PROTEIN_ALTERING_TYPES = {"missense", "nonsense", "deletion"}
#: PyMOL executable. Override with the ``PYMOL`` environment variable.
PYMOL_BIN = Path(os.environ.get("PYMOL", Path.home() / "pymol" / "pymol"))
#: ``fetch`` writes to the cwd unless told otherwise; keep downloads together.
PYMOL_FETCH_CACHE = DATA / "structures" / "pymol_fetch_cache"
#: MET's gene symbol is MET but the receptor is conventionally HGFR in this
#: manuscript. Only this one protein differs from an uppercase key.
_DISPLAY_OVERRIDES = {"met": "HGFR", "shp2": "SHP2", "kras": "KRAS", "mras": "MRAS"}
def protein_label(protein: str) -> str:
"""Display name for a protein key. MET renders as **HGFR**.
Also handles the synthetic scopes (``egfr_ss``, ``kras_ciar``) and split
libraries (``braf_cterm``) so any key in the data can be labelled.
>>> protein_label("met"), protein_label("braf_cterm")
('HGFR', 'BRAF (C)')
"""
key = str(protein).strip().lower()
suffix = ""
if key.endswith("_cterm"):
key, suffix = key[:-6], " (C)"
elif key.endswith("_nterm"):
key, suffix = key[:-6], " (N)"
elif key.endswith("_ss"):
key, suffix = key[:-3], " (serum-starved)"
elif key.endswith("_ciar"):
key, suffix = key[:-5], " (CIAR)"
return _DISPLAY_OVERRIDES.get(key, key.upper()) + suffix
def protein_labels(proteins: Iterable[str]) -> list[str]:
"""Vectorised :func:`protein_label`, for axis tick labels."""
return [protein_label(p) for p in proteins]
# ---------------------------------------------------------------------------
# Palettes
# ---------------------------------------------------------------------------
#: Locked across every HSP90 figure (docs/hsp90_metric_definitions.md).
BUFFERING_COLORS = {
"Buffered": "#bd2c2c",
"Poorly buffered": "#d9892d",
"WT-like or high": "#3a6fb5",
}
BUFFERING_ORDER = ["Buffered", "Poorly buffered", "WT-like or high"]
#: White -> class-colour ramps for the structure renders, so a painted fold
#: reads in the same colour as the class does in every 2D panel.
#: Bottom-to-top stack order for the buffering proportion bars (4C, 4G):
#: the WT-like majority sits at the base so the two buffered classes stack
#: against a common baseline and can be compared across bars by eye.
BUFFERING_STACK_ORDER = ["WT-like or high", "Buffered", "Poorly buffered"]
BUFFERING_CMAPS = {"Buffered": "Reds", "Poorly buffered": "Oranges",
"WT-like or high": "Blues"}
#: Activity-class palette, kept stable across figures.
ACTIVITY_COLORS = {
"DN": "#C0392B",
"low": "#26A69A",
"wt-like": "#F4E04D",
"high": "#E64A19",
}
ACTIVITY_ORDER = ["DN", "low", "wt-like", "high"]
#: Title-cased activity groups as used by the by-activity-group panels
#: (``scripts/analyze_structure_by_activity_group.py`` and the phyloP / RSA /
#: kinase-conservation companions). Same colours, ordered GOF -> most severe.
ACTIVITY_GROUP_ORDER = ["High", "WT-like", "Low", "DN"]
ACTIVITY_GROUP_COLOR = {
"High": "#E64A19", "WT-like": "#F4E04D", "Low": "#26A69A", "DN": "#C0392B",
}
#: Minimum variants in a (protein, group) cell for it to contribute a
#: per-protein median dot — the Simpson guard.
MIN_N_PER_PROTEIN = 10
#: Population / cancer databases.
DB_COLORS = {"gnomAD": "#3571b6", "AoU": "#b67a35", "GENIE": "#4f8f4f"}
#: Per-protein sphere colours for the structural renders, carried over from
#: ``scripts/paint_dn_counts_pdb_structures.py`` so a protein keeps one colour
#: across every structure it appears in.
PROTEIN_SPHERE_COLOR = {
"araf": (0.85, 0.65, 0.10), # mustard
"braf": (0.10, 0.28, 0.65), # deep blue
"craf": (0.25, 0.55, 0.85), # cornflower
"egfr": (0.70, 0.10, 0.15), # ruby
"erbb2": (0.65, 0.08, 0.12), # maroon
"kras": (0.90, 0.45, 0.05), # dark orange
"met": (0.55, 0.35, 0.10), # bronze
"mras": (0.85, 0.50, 0.10), # amber
"ret": (0.45, 0.05, 0.45), # dark magenta
"shp2": (0.15, 0.60, 0.30), # forest green
"sos1": (0.50, 0.05, 0.65), # purple
"sos2": (0.05, 0.55, 0.45), # teal
}
#: Cartoon and partner colours for the structural renders.
STRUCT_CARTOON_COLOR = "gray80"
STRUCT_PARTNER_COLORS = ("skyblue", "wheat", "lightpink")
#: Per-variant-type colours, carried over from
#: ``scripts/plot_dn_structural_enrichment_forest*.py`` so the DN forests match.
VARIANT_TYPE_COLORS = {
"missense": "#2166ac", # blue
"deletion": "#1b7837", # green
"nonsense": "#762a83", # purple
}
VARIANT_TYPE_ORDER = ["missense", "deletion", "nonsense"]
#: The same palette keyed by the `variant_category` vocabulary the scoring
#: pipeline emits, which names the single-codon class "3nt deletion" and keeps an
#: explicit "other" bucket (multi-mutants and larger in-frame deletions).
#: Synonymous, WT and the spiked standards are deliberately absent: they are
#: controls rather than library variants, so the panels using this palette plot
#: only the protein-altering classes.
VARIANT_CATEGORY_COLORS = {
"missense": "#2166ac", # blue
"3nt deletion": "#1b7837", # green
"nonsense": "#762a83", # purple
"frameshift": "#b2182b", # red
"other": "#8c8c8c", # grey
}
VARIANT_CATEGORY_ORDER = ["missense", "3nt deletion", "nonsense", "frameshift",
"other"]
#: DN structural-ontology (v4) bucket styling, carried over from
#: ``scripts/plot_dn_ontology_v4_coverage.py``. Each entry is
#: ``(facecolor, hatch_colour, hatch_pattern)``; the two-category combos are
#: drawn as the first category's fill hatched in the second's colour.
#:
#: Note there are six buckets and not seven: v4 defines Buried as
#: ``rSASA < 0.25 and not active site``, so B and AS are mutually exclusive by
#: construction and neither B+AS nor B+AS+I can occur.
ONTOLOGY_COLOR_B = "#d97b1f" # orange
ONTOLOGY_COLOR_AS = "#a64ca6" # purple
ONTOLOGY_COLOR_I = "#3a6fb5" # blue
ONTOLOGY_COLOR_NONE = "#ffffff" # white -- unexplained
ONTOLOGY_NONE_EDGE = "#999999"
ONTOLOGY_BUCKET_ORDER = ["B_only", "AS_only", "I_only", "B_I", "AS_I", "none"]
ONTOLOGY_BUCKET_STYLE: dict[str, tuple] = {
"B_only": (ONTOLOGY_COLOR_B, None, None),
"AS_only": (ONTOLOGY_COLOR_AS, None, None),
"I_only": (ONTOLOGY_COLOR_I, None, None),
"B_I": (ONTOLOGY_COLOR_B, ONTOLOGY_COLOR_I, "////"),
"AS_I": (ONTOLOGY_COLOR_AS, ONTOLOGY_COLOR_I, "////"),
"none": (ONTOLOGY_COLOR_NONE, None, None),
}
ONTOLOGY_BUCKET_LABELS = {
"B_only": "Buried (B)", "AS_only": "Active site (AS)",
"I_only": "Interface (I)", "B_I": "B + I", "AS_I": "AS + I",
"none": "Other",
}
#: Legend layout: single categories + Other on the top row, combos beneath.
ONTOLOGY_LEGEND_ROWS = [["B_only", "AS_only", "I_only", "none"],
["B_I", "AS_I"]]
# ---------------------------------------------------------------------------
# Style
# ---------------------------------------------------------------------------
_HELVETICA_DIR = Path.home() / ".local" / "share" / "fonts"
def apply_style(*, base_fontsize: float = 8.0, strict: bool = False) -> None:
"""Register Helvetica and set publication rcParams. Idempotent.
``pdf.fonttype=42`` and ``svg.fonttype="none"`` keep text as *editable
text* rather than outlined paths, which is what lets the panels be
assembled and re-labelled in Illustrator.
Args:
base_fontsize: Default text size in points.
strict: If True, raise when Helvetica is missing instead of falling
back to Arial/DejaVu. Use in the final figure run.
"""
ttfs = sorted(_HELVETICA_DIR.glob("Helvetica*.ttf"))
if ttfs:
for f in ttfs:
fm.fontManager.addfont(str(f))
elif strict:
raise FileNotFoundError(f"No Helvetica*.ttf in {_HELVETICA_DIR}")
plt.rcParams.update({
"font.family": "sans-serif",
"font.sans-serif": ["Helvetica", "Arial", "DejaVu Sans"],
"pdf.fonttype": 42,
"svg.fonttype": "none",
"font.size": base_fontsize,
"axes.labelsize": base_fontsize,
"axes.titlesize": base_fontsize + 1,
"xtick.labelsize": base_fontsize - 1,
"ytick.labelsize": base_fontsize - 1,
"legend.fontsize": base_fontsize - 1,
"axes.spines.top": False,
"axes.spines.right": False,
"axes.linewidth": 0.6,
"xtick.major.width": 0.6,
"ytick.major.width": 0.6,
"figure.dpi": 110,
"savefig.dpi": 300,
})
def save_figure(
fig: Figure,
stem: str | Path,
*,
formats: Sequence[str] = ("pdf", "svg", "png"),
tight: bool = True,
) -> list[Path]:
"""Save a figure to several formats beside one another.
PDF and SVG are the assembly formats (editable text); PNG is for quick
viewing in the notebook.
Args:
fig: Figure to write.
stem: Output path *without* extension. Parent dirs are created.
formats: Extensions to write.
tight: Use ``bbox_inches="tight"``. Safe with the fixed-pitch helpers
— it trims surrounding whitespace without resizing the data area.
Returns:
The paths written.
"""
stem = Path(stem)
stem.parent.mkdir(parents=True, exist_ok=True)
kw = {"bbox_inches": "tight"} if tight else {}
written = []
for ext in formats:
p = stem.with_suffix(f".{ext}")
fig.savefig(p, **kw)
written.append(p)
return written
def stars_4tier(p: float) -> str:
"""``****`` <1e-4, ``***`` <1e-3, ``**`` <1e-2, ``*`` <0.05, else ``ns``.
Convention of ``plot_class_depletion_global.py``. Non-finite -> "".
"""
if p is None or not np.isfinite(p):
return ""
return ("****" if p < 1e-4 else "***" if p < 1e-3 else
"**" if p < 1e-2 else "*" if p < 0.05 else "ns")
def stars_4tier_ns(p: float) -> str:
"""As :func:`stars_4tier` but ``n.s.`` for non-significant *and* for
non-finite. Convention of the by-activity-group conservation panels."""
if p is None or not np.isfinite(p):
return "n.s."
return ("****" if p < 1e-4 else "***" if p < 1e-3 else
"**" if p < 1e-2 else "*" if p < 0.05 else "n.s.")
# ---------------------------------------------------------------------------
# Plots
#
# Every builder below is a port of an existing project figure — colours, font
# sizes, marker sizes and panel dimensions are carried over verbatim so the new
# panels are interchangeable with the ones already in the manuscript. The
# defaults reproduce the established figure; the keyword arguments are there so
# a panel can be adjusted without a new function being written.
#
# Source of each port is named in the function docstring.
# ---------------------------------------------------------------------------
def plot_forest(
df: pd.DataFrame,
*,
label_col: str,
or_col: str = "or",
lo_col: str = "ci_low",
hi_col: str = "ci_high",
series_col: str | None = None,
label_order: Sequence[str] | None = None,
series_order: Sequence[str] | None = None,
series_colors: Mapping[str, str] | None = None,
series_dy: Mapping[str, float] | float = 0.24,
series_n: Mapping[str, int] | None = None,
figsize: tuple[float, float] = (7.6, 4.7),
xticks: Sequence[float] = (0.25, 0.5, 1, 2, 4, 8),
xlim_pad: tuple[float, float] = (0.80, 1.65),
xlabel: str = "Dominant negative odds ratio (pooled, 95% CI)",
title: str = "DN structural enrichment",
annotate_or: bool = True,
or_fmt: str = "{:.2f}",
annot_col: str | None = None,
markersize: float = 7.0,
annot_x_mult: float = 1.06,
elinewidth: float = 1.4,
capsize: float = 3.5,
legend_loc: str = "lower right",
legend_fmt: str = "{series} DN (n={n:,})",
fs: Mapping[str, float] | None = None,
) -> tuple[Figure, Axes]:
"""Dot + 95% CI odds-ratio forest on a log axis.
Port of ``scripts/plot_dn_structural_enrichment_forest_pooled.py`` — same
7.6x4.7 in panel, per-variant-type colours, +/-0.24 vertical offsets,
7.0 pt markers, 1.4 pt whiskers with 3.5 pt caps, dashed OR=1 reference at
``#999999``, log ticks 0.25-8, the OR value printed at 1.06x the CI upper
bound, and a light-framed legend in the lower-right corner. Font sizes are
~1.5x print-final so the panel stays legible after the 67% downscale used
when placing it.
Also used for the population-depletion forest by passing
``series_colors=DB_COLORS`` and a different ``xlabel``/``title``.
Args:
df: One row per (label, series) with the OR and its interval.
label_col: Column holding the y-axis category.
series_col: Optional column splitting into vertically-offset series.
label_order / series_order: Explicit ordering; defaults to order of
appearance. ``label_order`` is top-to-bottom.
series_colors: series -> colour. Defaults to
:data:`VARIANT_TYPE_COLORS`.
series_dy: Vertical offset per series, or a scalar half-spread that is
spread evenly across the series.
series_n: series -> n, for the legend.
xlim_pad: Multiplicative padding on (min CI low, max CI high).
fs: Font-size overrides; keys ``title``, ``y``, ``x``, ``tick``,
``or``, ``legend``.
"""
F = {"title": 16, "y": 13, "x": 14, "tick": 12, "or": 9.5, "legend": 11}
F.update(fs or {})
labels = list(label_order or dict.fromkeys(df[label_col]))
series = list(series_order or (dict.fromkeys(df[series_col])
if series_col else [None]))
colors = dict(series_colors or VARIANT_TYPE_COLORS)
for i, s in enumerate(series):
colors.setdefault(s, list(VARIANT_TYPE_COLORS.values())[
i % len(VARIANT_TYPE_COLORS)])
if isinstance(series_dy, Mapping):
dy = dict(series_dy)
elif len(series) > 1:
dy = dict(zip(series, np.linspace(series_dy, -series_dy, len(series))))
else:
dy = {series[0]: 0.0}
n_lab = len(labels)
y_of = {k: n_lab - i for i, k in enumerate(labels)}
fig, ax = plt.subplots(figsize=figsize)
xmin, xmax = np.inf, -np.inf
for s in series:
sub = df if s is None else df[df[series_col] == s]
col = colors[s]
for _, r in sub.iterrows():
if r[label_col] not in y_of or not np.isfinite(r[or_col]):
continue
y = y_of[r[label_col]] + dy.get(s, 0.0)
v, lo, hi = float(r[or_col]), float(r[lo_col]), float(r[hi_col])
if not (np.isfinite(lo) and np.isfinite(hi)):
lo = hi = v
xmin, xmax = min(xmin, lo), max(xmax, hi)
ax.errorbar(v, y, xerr=[[max(v - lo, 0)], [max(hi - v, 0)]],
fmt="none", ecolor=col, elinewidth=elinewidth,
capsize=capsize, capthick=elinewidth, alpha=0.9,
zorder=2)
ax.plot(v, y, marker="o", markersize=markersize, linestyle="",
color=col, markeredgecolor="white", markeredgewidth=0.7,
zorder=3)
if annot_col is not None:
txt = str(r.get(annot_col, "") or "")
elif annotate_or:
txt = or_fmt.format(v)
else:
txt = ""
if txt:
ax.text(hi * annot_x_mult, y, txt, ha="left", va="center",
fontsize=F["or"], color=col, zorder=3, clip_on=False)
ax.axvline(1.0, color="#999999", linewidth=1.0, linestyle="--", zorder=1)
ax.set_xscale("log")
if np.isfinite(xmin):
ax.set_xlim(xmin * xlim_pad[0], xmax * xlim_pad[1])
ax.set_xticks(list(xticks))
ax.set_xticklabels([str(t) for t in xticks], fontsize=F["tick"])
ax.minorticks_off()
ax.set_yticks([y_of[k] for k in labels])
ax.set_yticklabels(labels, fontsize=F["y"])
ax.set_ylim(0.4, n_lab + 0.6)
ax.set_xlabel(xlabel, fontsize=F["x"])
if title:
ax.set_title(title, fontsize=F["title"], pad=8)
for s in ("top", "right"):
ax.spines[s].set_visible(False)
ax.tick_params(length=3)
if series_col is not None:
handles = [
plt.Line2D([0], [0], marker="o", linestyle="",
markersize=markersize, markerfacecolor=colors[s],
markeredgecolor=colors[s],
label=legend_fmt.format(series=s,
n=(series_n or {}).get(s, 0)))
for s in series]
leg = ax.legend(handles=handles, fontsize=F["legend"], loc=legend_loc,
frameon=True, handletextpad=0.4, labelspacing=0.4,
borderaxespad=0.7)
leg.get_frame().set_edgecolor("#cccccc")
leg.get_frame().set_facecolor("white")
leg.get_frame().set_linewidth(0.6)
fig.tight_layout()
return fig, ax
def fixed_pitch_bar_axes(
n_bars: int,
*,
pitch_in: float = 0.95,
axes_h_in: float = 3.40,
left_in: float = 0.95,
right_in: float = 0.30,
bottom_in: float = 1.15,
top_in: float = 0.50,
legend_in: float = 0.0,
) -> tuple[Figure, Axes]:
"""Figure + axes whose data area is a fixed physical width *per category*.
Port of the helper in ``scripts/_plot_style.py``. Two stacked-bar panels can
both pass ``width=0.62`` and still draw bars of different physical width,
because a data unit maps to a different number of inches when the axes spans
a different x-range (4 bars vs 3) or is squeezed by an outside legend. This
pins the axes to an explicit inch rectangle and sets ``xlim`` so one
category is exactly one data unit == ``pitch_in`` inches; a bar drawn at
``width=w`` is then always ``w * pitch_in`` inches wide whatever ``n_bars``
is. That is what lets 4C and 4G show bars of identical width.
Bars go at integer x positions ``0 .. n_bars-1``. Because the axes is placed
with ``add_axes``, callers must **not** call ``fig.tight_layout()``;
``savefig(bbox_inches="tight")`` is fine — it trims surrounding whitespace
without resizing the data area.
Args:
n_bars: Number of bar categories.
pitch_in: Inches per category slot. Equal across panels => equal bars.
axes_h_in: Height of the data area, in inches.
left_in, right_in, bottom_in, top_in: Figure margins, in inches.
legend_in: Extra width reserved at the right for an outside legend.
Does not affect bar width; it only stops the legend being clipped.
"""
data_w = n_bars * pitch_in
fig_w = left_in + data_w + right_in + legend_in
fig_h = bottom_in + axes_h_in + top_in
fig = plt.figure(figsize=(fig_w, fig_h))
ax = fig.add_axes([left_in / fig_w, bottom_in / fig_h,
data_w / fig_w, axes_h_in / fig_h])
ax.set_xlim(-0.5, n_bars - 0.5)
return fig, ax
def plot_stacked_vbar(
props: pd.DataFrame,
*,
colors: Mapping[str, str],
n_by_row: Mapping[str, int] | pd.Series | None = None,
xtick_fmt: str = "{label}\n(n={n:,})",
ylabel: str = "Proportion of missense variants",
legend_title: str = "WT-relative category",
annot_min: float = 0.05,
annot_fmt: str = "{:.2f}",
bar_width: float = 0.62,
ylim: tuple[float, float] = (0.0, 1.02),
show_legend: bool = True,
yticks: Sequence[float] | None = None,
pitch_in: float = 0.95,
legend_in: float = 1.70,
axes_h_in: float = 3.40,
fs: Mapping[str, float] | None = None,
) -> tuple[Figure, Axes]:
"""Vertical stacked proportion bars on a fixed physical bar pitch.
Port of ``plot_stacked_bar`` in
``scripts/plot_hsp90_wt_category_by_domain.py`` — bars at ``width=0.62``
with white 0.6 pt separators, every segment at or above ``annot_min``
labelled in bold white inside the segment, x-tick labels carrying the group
N, and a frameless titled legend outside the axes on the right.
Args:
props: Rows = bar groups in order, columns = stack segments in bottom-
to-top order. Values are proportions.
colors: Segment name -> colour.
n_by_row: Group -> N, for the x-tick labels. Omit to label bare.
annot_min: Segments smaller than this share are left unannotated.
show_legend: Draw the outside legend. The activity-class panel omits it
and inherits its colour key from the panel beside it.
yticks: Explicit y ticks; ``None`` leaves matplotlib's choice.
fs: Font-size overrides; keys ``annot``, ``xtick``, ``ylabel``,
``ytick``, ``legend``, ``legend_title``.
"""
F = {"annot": 11.0, "xtick": 12.0, "ylabel": 13.0, "ytick": 11.0,
"legend": 11.0, "legend_title": 12.0}
F.update(fs or {})
groups = list(props.index)
fig, ax = fixed_pitch_bar_axes(len(groups), pitch_in=pitch_in,
axes_h_in=axes_h_in, legend_in=legend_in)
bottoms = np.zeros(len(groups))
for seg in props.columns:
vals = props[seg].to_numpy(dtype=float)
ax.bar(range(len(groups)), vals, bottom=bottoms, label=str(seg),
color=colors[seg], width=bar_width, linewidth=0.6,
edgecolor="white")
for i, (v, b) in enumerate(zip(vals, bottoms)):
if v >= annot_min:
ax.text(i, b + v / 2, annot_fmt.format(v), ha="center",
va="center", fontsize=F["annot"], color="white",
fontweight="bold")
bottoms += vals
ax.set_xticks(range(len(groups)))
if n_by_row is not None:
ax.set_xticklabels([xtick_fmt.format(label=g, n=int(n_by_row[g]))
for g in groups], fontsize=F["xtick"])
else:
ax.set_xticklabels([str(g) for g in groups], fontsize=F["xtick"])
ax.set_ylabel(ylabel, fontsize=F["ylabel"])
ax.set_ylim(*ylim)
ax.set_xlabel("")
ax.tick_params(axis="y", labelsize=F["ytick"])
if yticks is not None:
ax.set_yticks(list(yticks))
if show_legend:
ax.legend(loc="upper left", bbox_to_anchor=(1.02, 1.0),
fontsize=F["legend"], frameon=False, title=legend_title,
title_fontsize=F["legend_title"])
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
return fig, ax
def plot_stacked_hbar(
frac: pd.DataFrame,
*,
counts: Mapping[str, int] | pd.Series | None = None,
bucket_style: Mapping[str, tuple] | None = None,
bucket_order: Sequence[str] | None = None,
bucket_labels: Mapping[str, str] | None = None,
legend_rows: Sequence[Sequence[str]] | None = None,
pooled_row: str | None = None,
bar_h: float = 0.82,
pooled_gutter_mult: float = 1.5,
annotate_threshold: float = 0.10,
fig_w: float = 8.0,
bar_pitch_in: float = 0.33,
top_margin_in: float = 0.20,
bottom_margin_in: float = 0.06,
legend_in: float = 0.55,
xlabel_gap_in: float = 0.55,
xlabel: str = "Fraction of dominant negatives",
n_fmt: str = "n = {:,}",
fs: Mapping[str, float] | None = None,
) -> tuple[Figure, Axes]:
"""Horizontal stacked composition bars, one per category.
Port of ``scripts/plot_dn_ontology_v4_pooled_coverage_horizontal.py``:
8.0 in wide, 0.82-thick bars so rows nearly touch, hatched overlays for
the two-category combos, white segment labels above 10%, ``n = X`` at the
right edge, and a flow legend band beneath. An optional pooled
"ALL PROTEINS" bar is separated by 1.5x the normal gutter.
``bucket_style`` maps bucket -> ``(facecolor, hatch_colour, hatch_pattern)``
with the last two None for a solid fill; defaults to
:data:`ONTOLOGY_BUCKET_STYLE`.
Args:
frac: Rows are bars, columns are buckets; values are fractions.
counts: Per-bar n, printed at the right edge.
pooled_row: Index label to place last and separate by a wider gutter.
annotate_threshold: Segments at or below this fraction go unlabelled.
fs: Font-size overrides; keys ``cat``, ``tick``, ``axis``, ``seg``,
``n``, ``legend``.
"""
F = {"cat": 14, "tick": 13, "axis": 14, "seg": 11, "n": 9, "legend": 13}
F.update(fs or {})
style = dict(bucket_style or ONTOLOGY_BUCKET_STYLE)
order = list(bucket_order or [b for b in ONTOLOGY_BUCKET_ORDER
if b in frac.columns])
labels = dict(bucket_labels or ONTOLOGY_BUCKET_LABELS)
rows = [r for r in frac.index if r != pooled_row]
y_of = {r: i for i, r in enumerate(rows)}
normal_gutter = 1.0 - bar_h
if pooled_row is not None and pooled_row in frac.index:
y_of[pooled_row] = (len(rows) - 1) + bar_h + pooled_gutter_mult * normal_gutter
n_rows_eq = max(y_of.values()) + 1
bars_in = bar_pitch_in * n_rows_eq
fig_h = top_margin_in + bars_in + xlabel_gap_in + legend_in + bottom_margin_in
fig = plt.figure(figsize=(fig_w, fig_h))
ax = fig.add_axes([0.11, (bottom_margin_in + legend_in + xlabel_gap_in) / fig_h,
0.80, bars_in / fig_h])
legend_ax = fig.add_axes([0.07, bottom_margin_in / fig_h, 0.92,
legend_in / fig_h])
for row, y in y_of.items():
cum = 0.0
for b in order:
f = float(frac.loc[row, b]) if b in frac.columns else 0.0
if f <= 0 or not np.isfinite(f):
continue
face, hatch_col, hatch_pat = style.get(b, ("#999999", None, None))
is_none = b in ("none", "Other")
ax.add_patch(plt.Rectangle(
(cum, y - bar_h / 2), f, bar_h, facecolor=face,
edgecolor=ONTOLOGY_NONE_EDGE if is_none else "white",
linewidth=0.5 if is_none else 0.3, zorder=2))
if hatch_pat:
ax.add_patch(plt.Rectangle(
(cum, y - bar_h / 2), f, bar_h, facecolor="none",
edgecolor=hatch_col, linewidth=0, hatch=hatch_pat, zorder=3))
if f > annotate_threshold:
ax.text(cum + f / 2, y, f"{int(round(f * 100))}%",
ha="center", va="center", fontsize=F["seg"],
color="black" if is_none else "white", zorder=4)
cum += f
if counts is not None and row in counts:
ax.text(1.01, y, n_fmt.format(int(counts[row])), ha="left",
va="center", fontsize=F["n"], color="#444444")
# xlim to 1.15 leaves room for the "n = X" labels at x=1.01.
ax.set_xlim(0, 1.15)
ax.set_ylim(-0.5, max(y_of.values()) + 0.5)
ax.invert_yaxis()
ax.set_yticks(list(y_of.values()))
ax.set_yticklabels([protein_label(r) if str(r).lower() in PROTEIN_KEYS
else str(r) for r in y_of], fontsize=F["cat"])
if pooled_row is not None and pooled_row in frac.index:
ax.get_yticklabels()[-1].set_fontweight("bold")
ax.set_xticks([0, 0.25, 0.5, 0.75, 1.0])
ax.set_xticklabels(["0", "0.25", "0.5", "0.75", "1"], fontsize=F["tick"])
ax.set_xlabel(xlabel, fontsize=F["axis"])
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
ax.spines["left"].set_color("#888")
ax.tick_params(axis="y", length=0)
# Two-row flowed legend, each row centred. Item widths are *measured* from
# the renderer rather than estimated, so long names ("Active site (AS)")
# and short ones ("AS + I") coexist without a fixed grid clipping either.
legend_ax.set_xlim(0, 1)
legend_ax.set_ylim(0, 1)
legend_ax.axis("off")
sw, sh, gap, item_gap = 0.045, 0.24, 0.010, 0.024
fig.canvas.draw()
rend = fig.canvas.get_renderer()
ax_w = legend_ax.get_window_extent(rend).width
def _text_w(s: str) -> float:
probe = legend_ax.text(0, -2, s, fontsize=F["legend"])
w = probe.get_window_extent(rend).width / ax_w
probe.remove()
return w
rows = legend_rows or [[b for b in order]]
for row_buckets, y0 in zip(rows, (0.70, 0.24)):
row_buckets = [b for b in row_buckets if b in style]
if not row_buckets:
continue
widths = [sw + gap + _text_w(labels.get(b, b)) for b in row_buckets]
total = sum(widths) + item_gap * (len(row_buckets) - 1)
x = (1.0 - total) / 2.0
for b, w in zip(row_buckets, widths):
face, hatch_col, hatch_pat = style[b]
is_none = b in ("none", "Other")
legend_ax.add_patch(plt.Rectangle(
(x, y0 - sh / 2), sw, sh, facecolor=face,
edgecolor=ONTOLOGY_NONE_EDGE if is_none else "#888",
linewidth=0.6 if is_none else 0.3))
if hatch_pat:
legend_ax.add_patch(plt.Rectangle(
(x, y0 - sh / 2), sw, sh, facecolor="none",
edgecolor=hatch_col, linewidth=0, hatch=hatch_pat))