From 1c8f4ae949b3e7f5b07f35c58d7d8114b509f14a Mon Sep 17 00:00:00 2001 From: Yan Wong Date: Wed, 3 Mar 2021 12:14:11 +0000 Subject: [PATCH 1/5] Set mutation positions using mutation time if available --- python/tests/data/svg/internal_sample_ts.svg | 358 ++- python/tests/data/svg/tree.svg | 26 +- .../data/svg/tree_mutations_no_edges.svg | 68 + .../data/svg/{mut_tree.svg => tree_muts.svg} | 40 +- python/tests/data/svg/ts.svg | 170 +- python/tests/data/svg/ts_mut_highlight.svg | 170 +- python/tests/data/svg/ts_mut_times.svg | 297 ++ .../tests/data/svg/ts_mut_times_logscale.svg | 297 ++ .../tests/data/svg/ts_mutations_no_edges.svg | 112 + python/tests/data/svg/ts_nonbinary.svg | 2532 ++++++++--------- python/tests/data/svg/ts_plain.svg | 164 +- python/tests/data/svg/ts_rank.svg | 297 ++ python/tests/data/svg/ts_xlabel.svg | 170 +- python/tests/test_drawing.py | 100 +- python/tskit/drawing.py | 209 +- 15 files changed, 3236 insertions(+), 1774 deletions(-) create mode 100644 python/tests/data/svg/tree_mutations_no_edges.svg rename python/tests/data/svg/{mut_tree.svg => tree_muts.svg} (73%) create mode 100644 python/tests/data/svg/ts_mut_times.svg create mode 100644 python/tests/data/svg/ts_mut_times_logscale.svg create mode 100644 python/tests/data/svg/ts_mutations_no_edges.svg create mode 100644 python/tests/data/svg/ts_rank.svg diff --git a/python/tests/data/svg/internal_sample_ts.svg b/python/tests/data/svg/internal_sample_ts.svg index c803c8c3ad..121dfdb188 100644 --- a/python/tests/data/svg/internal_sample_ts.svg +++ b/python/tests/data/svg/internal_sample_ts.svg @@ -1,95 +1,331 @@ - + - + - + + + - + - - - - + + + + + + 0 + + + 1 - - - 1 - - - + + + 4 + + + + 2 - - - 6 - + + + + 3 + + + + - 3 + 2 + + 5 + + + + + + 0 + + + + + 1 - - - + + 9 + + + + + 7 + + + + + 8 + + + + + + + + + 0 - - - - - 3 - - - - - 4 - - + + + + 1 + + + + + + 3 + + + + + 4 + + + 4 + + + + + + 2 + + + - 5 - - - 4 - - - - 2 - - - - 0 - - - + 3 + + - 7 - - - 5 + 5 + + + + + + 5 + + + 7 + + + + + 8 + + + + + + + + + + + 0 + + + + + 1 + + + 4 + + + + + 2 + + + + + 3 + + + + 5 + + - 8 + 6 + + + + + 7 + + + + + 8 + + + + + + + + + + + 0 + + + + + 1 + + + + 4 + + + + + + 2 + + + + + 3 + + + + 5 + + + + 7 + + + + + 8 + + + + + + + + + 7 + + + + + + + 0 + + + + + 1 + + + + 4 + + + + + + 2 + + + + + 3 + + + + 5 + + + + 8 - - - - 0 + + + + 0.00 + + + + 0.06 + + + + 0.79 + + + + 0.91 + + + + 0.91 + + + + 1.00 + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - 1 + + diff --git a/python/tests/data/svg/tree.svg b/python/tests/data/svg/tree.svg index c40fa7724e..efd2a89fc3 100644 --- a/python/tests/data/svg/tree.svg +++ b/python/tests/data/svg/tree.svg @@ -4,34 +4,34 @@ - - - - + + + + 0 - - + + 1 - + 4 - - - + + + 2 - - + + 3 - + 5 diff --git a/python/tests/data/svg/tree_mutations_no_edges.svg b/python/tests/data/svg/tree_mutations_no_edges.svg new file mode 100644 index 0000000000..407a2f7e4c --- /dev/null +++ b/python/tests/data/svg/tree_mutations_no_edges.svg @@ -0,0 +1,68 @@ + + + + + + + + + + 0 + + + + + 1 + + + + + 2 + + + + + 3 + + + + + 4 + + + + + + + 0 + + + 5 + + + + + 6 + + + + + + + 1 + + + 7 + + + + + 8 + + + + + 9 + + + diff --git a/python/tests/data/svg/mut_tree.svg b/python/tests/data/svg/tree_muts.svg similarity index 73% rename from python/tests/data/svg/mut_tree.svg rename to python/tests/data/svg/tree_muts.svg index dc97d29d12..cecfef18ca 100644 --- a/python/tests/data/svg/mut_tree.svg +++ b/python/tests/data/svg/tree_muts.svg @@ -4,50 +4,50 @@ - - - - + + + + 0 - - + + 1 - + 4 - - - + + + 2 - - + + 3 - - - + + + 2 5 - - - + + + 0 - - + + 1 diff --git a/python/tests/data/svg/ts.svg b/python/tests/data/svg/ts.svg index 22ddfca34b..aec24a6bc9 100644 --- a/python/tests/data/svg/ts.svg +++ b/python/tests/data/svg/ts.svg @@ -5,57 +5,57 @@ - - - + + + - - - - + + + + 0 - - + + 1 - + 4 - - - + + + 2 - - + + 3 - - - + + + 2 5 - - - + + + 0 - - + + 1 @@ -66,50 +66,50 @@ - - - - + + + + 0 - - + + 1 - - - + + + 3 - - + + 4 4 - - - + + + 2 - - + + 3 - + 5 - - - + + + 5 @@ -120,38 +120,38 @@ - - - - + + + + 0 - - + + 1 - + 4 - - - + + + 2 - - + + 3 - + 5 - + 6 @@ -159,38 +159,38 @@ - - - - + + + + 0 - - + + 1 - + 4 - - - + + + 2 - - + + 3 - + 5 - + 7 @@ -198,38 +198,38 @@ - - - - + + + + 0 - - + + 1 - + 4 - - - + + + 2 - - + + 3 - + 5 - + 8 diff --git a/python/tests/data/svg/ts_mut_highlight.svg b/python/tests/data/svg/ts_mut_highlight.svg index 07ae0576f2..45b726efc2 100644 --- a/python/tests/data/svg/ts_mut_highlight.svg +++ b/python/tests/data/svg/ts_mut_highlight.svg @@ -5,57 +5,57 @@ - - - + + + - - - - + + + + 0 - - + + 1 - + 4 - - - + + + 2 - - + + 3 - - - + + + 2 5 - - - + + + 0 - - + + 1 @@ -66,50 +66,50 @@ - - - - + + + + 0 - - + + 1 - - - + + + 3 - - + + 4 4 - - - + + + 2 - - + + 3 - + 5 - - - + + + 5 @@ -120,38 +120,38 @@ - - - - + + + + 0 - - + + 1 - + 4 - - - + + + 2 - - + + 3 - + 5 - + 6 @@ -159,38 +159,38 @@ - - - - + + + + 0 - - + + 1 - + 4 - - - + + + 2 - - + + 3 - + 5 - + 7 @@ -198,38 +198,38 @@ - - - - + + + + 0 - - + + 1 - + 4 - - - + + + 2 - - + + 3 - + 5 - + 8 diff --git a/python/tests/data/svg/ts_mut_times.svg b/python/tests/data/svg/ts_mut_times.svg new file mode 100644 index 0000000000..133cd86178 --- /dev/null +++ b/python/tests/data/svg/ts_mut_times.svg @@ -0,0 +1,297 @@ + + + + + + + + + + + + + + + + + + + + 0 + + + + + 1 + + + + 4 + + + + + + 2 + + + + + 3 + + + + + + 2 + + + 5 + + + + + + 0 + + + + + 1 + + + 9 + + + + + + + + + + + 0 + + + + + 1 + + + + + + 3 + + + + + 4 + + + 4 + + + + + + 2 + + + + + 3 + + + + 5 + + + + + + 5 + + + 7 + + + + + + + + + + + 0 + + + + + 1 + + + + 4 + + + + + + 2 + + + + + 3 + + + + 5 + + + + 6 + + + + + + + + + + + 0 + + + + + 1 + + + + 4 + + + + + + 2 + + + + + 3 + + + + 5 + + + + 7 + + + + + + + + + + + 0 + + + + + 1 + + + + 4 + + + + + + 2 + + + + + 3 + + + + 5 + + + + 8 + + + + + + + + + 0.00 + + + + 0.06 + + + + 0.79 + + + + 0.91 + + + + 0.91 + + + + 1.00 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/python/tests/data/svg/ts_mut_times_logscale.svg b/python/tests/data/svg/ts_mut_times_logscale.svg new file mode 100644 index 0000000000..77a841e90c --- /dev/null +++ b/python/tests/data/svg/ts_mut_times_logscale.svg @@ -0,0 +1,297 @@ + + + + + + + + + + + + + + + + + + + + 0 + + + + + 1 + + + + 4 + + + + + + 2 + + + + + 3 + + + + + + 2 + + + 5 + + + + + + 0 + + + + + 1 + + + 9 + + + + + + + + + + + 0 + + + + + 1 + + + + + + 3 + + + + + 4 + + + 4 + + + + + + 2 + + + + + 3 + + + + 5 + + + + + + 5 + + + 7 + + + + + + + + + + + 0 + + + + + 1 + + + + 4 + + + + + + 2 + + + + + 3 + + + + 5 + + + + 6 + + + + + + + + + + + 0 + + + + + 1 + + + + 4 + + + + + + 2 + + + + + 3 + + + + 5 + + + + 7 + + + + + + + + + + + 0 + + + + + 1 + + + + 4 + + + + + + 2 + + + + + 3 + + + + 5 + + + + 8 + + + + + + + + + 0.00 + + + + 0.06 + + + + 0.79 + + + + 0.91 + + + + 0.91 + + + + 1.00 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/python/tests/data/svg/ts_mutations_no_edges.svg b/python/tests/data/svg/ts_mutations_no_edges.svg new file mode 100644 index 0000000000..92caa47e64 --- /dev/null +++ b/python/tests/data/svg/ts_mutations_no_edges.svg @@ -0,0 +1,112 @@ + + + + + + + + + + + + + + + + 0 + + + + + 1 + + + + + 2 + + + + + 3 + + + + + 4 + + + + + + + 0 + + + 5 + + + + + 6 + + + + + + + 3 + + + 7 + + + + + 8 + + + + + 9 + + + + + + + + + 0 + + + + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/python/tests/data/svg/ts_nonbinary.svg b/python/tests/data/svg/ts_nonbinary.svg index 6f7628752c..1a409c1a6b 100644 --- a/python/tests/data/svg/ts_nonbinary.svg +++ b/python/tests/data/svg/ts_nonbinary.svg @@ -5,114 +5,114 @@ - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + - - - - + + + + 7 - - - + + + 0 - - + + 1 - - + + 3 - - + + 4 - + 13 - - - + + + 0 - - + + 1 15 - - - + + + 2 - - - + + + 5 - - - + + + 8 - - - + + + 6 - - + + 9 - + 10 - + 11 - + 12 - + 17 @@ -123,131 +123,131 @@ - - - - + + + + 7 - - - + + + 0 - - + + 1 - - + + 3 - - + + 4 - - - + + + 10 13 - - - + + + 2 - - + + 3 - - + + 5 - - + + 8 - - + + 11 15 - - - + + + 2 - - - + + + 5 - - - + + + 8 - - - + + + 6 - - + + 9 - + 10 - + 11 - - - + + + 7 12 - - - + + + 4 - - + + 6 - - + + 9 @@ -261,84 +261,84 @@ - - - - + + + + 5 - - - + + + 8 - - - + + + 6 - - + + 9 - + 10 - + 11 - + 12 - - - + + + 2 - - - + + + 7 - - - + + + 0 - - + + 1 - - + + 3 - - + + 4 - + 13 - + 15 - + 29 @@ -349,99 +349,99 @@ - - - - + + + + 7 - - - + + + 0 - - + + 1 - - + + 3 - - + + 4 - + 13 - - - + + + 12 - - + + 14 15 - - - - - + + + + + 13 2 - - - + + + 5 - - - + + + 8 - - - + + + 6 - - + + 9 - + 10 - + 11 - + 12 - + 25 @@ -452,99 +452,99 @@ - - - - + + + + 5 - - - + + + 8 - - - + + + 6 - - + + 9 - + 10 - + 11 - + 12 - - - - - + + + + + 15 - - + + 16 - - + + 17 2 - - - + + + 7 - - - + + + 0 - - + + 1 - - + + 3 - - + + 4 - + 13 - + 15 - + 24 @@ -555,89 +555,89 @@ - - - - + + + + 7 - - - + + + 0 - - + + 1 - - + + 3 - - + + 4 - + 13 - + 15 - - - - + + + + 5 - - - + + + 8 - - - + + + 6 - - + + 9 - + 10 - + 11 - + 12 - - - - + + + + 18 2 - + 25 @@ -648,99 +648,99 @@ - - - - + + + + 7 - - - + + + 0 - - + + 1 - - + + 3 - - + + 4 - + 13 - - - + + + 21 15 - - - - - + + + + + 19 - - + + 20 2 - - - + + + 5 - - - + + + 8 - - - + + + 6 - - + + 9 - + 10 - + 11 - + 12 - + 18 @@ -751,96 +751,96 @@ - - - - + + + + 7 - - - + + + 0 - - + + 1 - - + + 3 - - + + 4 - + 13 - + 15 - - - - - + + + + + 24 2 - - - + + + 5 - - - + + + 8 - - - + + + 6 - - + + 9 - + 10 - + 11 - - - + + + 22 12 - - - + + + 23 @@ -854,84 +854,84 @@ - - - - + + + + 0 - - + + 1 - - + + 3 - - + + 4 - + 13 - - - + + + 7 - - - + + + 2 - - - + + + 5 - - - + + + 8 - - - + + + 6 - - + + 9 - + 10 - + 11 - + 12 - + 18 - + 20 @@ -942,106 +942,106 @@ - - - - + + + + 0 - - + + 1 - - + + 3 - - + + 4 - - - - + + + + 26 2 - - - + + + 25 - - + + 27 - - + + 29 13 - - - - - + + + + + 28 7 - - - + + + 5 - - - + + + 8 - - - + + + 6 - - + + 9 - + 10 - + 11 - + 12 - - - + + + 30 @@ -1055,104 +1055,104 @@ - - - - + + + + 0 - - + + 1 - - + + 3 - - + + 4 - - - - + + + + 33 2 - - - + + + 31 - - + + 35 13 - - - + + + 7 - - - + + + 5 - - - - - + + + + + 32 8 - - - + + + 6 - - + + 9 - + 10 - + 11 - - - + + + 34 12 - + 16 @@ -1163,96 +1163,96 @@ - - - - + + + + 0 - - + + 1 - - + + 3 - - + + 4 - - + + 2 - - - + + + 36 - - + + 37 - - + + 39 13 - - - + + + 7 - - - + + + 5 - - - + + + 8 - - - + + + 6 - - + + 9 - + 10 - + 11 - + 12 - - - + + + 38 @@ -1266,111 +1266,111 @@ - - - - + + + + 0 - - + + 1 - - + + 3 - - + + 4 - - + + 2 - - - + + + 42 - - + + 45 13 - - - - - + + + + + 46 7 - - - + + + 5 - - - + + + 8 - - - + + + 6 - - + + 9 - + 10 - + 11 - - - + + + 41 - - + + 44 12 - - - + + + 40 - - + + 43 @@ -1384,79 +1384,79 @@ - - - - + + + + 0 - - + + 1 - - + + 3 - - + + 4 - - + + 2 - + 13 - - - + + + 7 - - - + + + 5 - - - + + + 8 - - - + + + 6 - - + + 9 - + 10 - + 11 - + 12 - + 16 @@ -1467,111 +1467,111 @@ - - - - + + + + 0 - - + + 1 - - + + 3 - - + + 4 - - + + 2 - - - + + + 47 - - + + 48 - - + + 50 - - + + 51 - - + + 53 13 - - - + + + 7 - - - + + + 5 - - - + + + 8 - - - + + + 6 - - + + 9 - + 10 - + 11 - + 12 - - - + + + 49 - - + + 52 @@ -1585,86 +1585,86 @@ - - - - + + + + 0 - - + + 1 - - + + 3 - - + + 4 - - + + 2 - - - + + + 55 13 - - - + + + 7 - - - + + + 5 - - - + + + 8 - - - + + + 6 - - + + 9 - + 10 - + 11 - + 12 - - - + + + 54 @@ -1678,89 +1678,89 @@ - - - + + + 0 - - - - + + + + 1 - - + + 3 - - + + 4 - - + + 2 - + 13 - - - - - + + + + + 56 7 - - - + + + 5 - - - + + + 8 - - - + + + 6 - - + + 9 - + 10 - + 11 - + 12 - + 16 - + 19 @@ -1771,96 +1771,96 @@ - - - - - + + + + + 57 0 - - - - + + + + 1 - - + + 3 - - + + 4 - - + + 2 - + 13 - - - + + + 7 - - - + + + 5 - - - + + + 8 - - - + + + 6 - - + + 9 - + 10 - + 11 - - - + + + 58 12 - + 16 - - - + + + 59 @@ -1874,89 +1874,89 @@ - - - - - + + + + + 60 0 - - - - + + + + 1 - - + + 3 - - + + 4 - - + + 2 - + 13 - - - + + + 7 - - - + + + 5 - - - + + + 8 - - - + + + 6 - - + + 9 - + 10 - + 11 - + 12 - + 16 - + 19 @@ -1967,84 +1967,84 @@ - - - + + + 0 - - - - + + + + 1 - - + + 3 - - + + 4 - - + + 2 - + 13 - - - + + + 7 - - - + + + 5 - - - + + + 8 - - - + + + 6 - - + + 9 - + 10 - + 11 - + 12 - + 16 - + 19 @@ -2055,96 +2055,96 @@ - - - + + + 0 - - - - + + + + 1 - - + + 3 - - + + 4 - - + + 2 - + 13 - - - - - + + + + + 61 7 - - - + + + 5 - - - + + + 8 - - - + + + 6 - - + + 9 - + 10 - + 11 - + 12 - - - + + + 63 16 - - - + + + 62 @@ -2158,109 +2158,109 @@ - - - - + + + + 1 - - + + 3 - - + + 4 - - + + 2 - - - + + + 64 - - + + 65 - - + + 66 - - + + 67 13 - - - + + + 0 - - - + + + 7 - - - + + + 5 - - - + + + 8 - - - + + + 6 - - + + 9 - + 10 - + 11 - + 12 - - - + + + 68 16 - + 32 @@ -2271,101 +2271,101 @@ - - - - + + + + 7 - - - + + + 5 - - - + + + 8 - - - + + + 6 - - + + 9 - + 10 - + 11 - + 12 - - - + + + 72 16 - - - + + + 0 - - - + + + 1 - - + + 3 - - + + 4 - - + + 2 - + 13 - - - + + + 69 - - + + 70 - - + + 71 @@ -2379,89 +2379,89 @@ - - - - + + + + 7 - - - + + + 5 - - - + + + 8 - - - + + + 6 - - + + 9 - + 10 - + 11 - + 12 - - - + + + 73 16 - - - + + + 0 - - - + + + 1 - - + + 3 - - + + 4 - - + + 2 - + 13 - + 23 @@ -2472,146 +2472,146 @@ - - - - + + + + 1 - - + + 3 - - + + 4 - - + + 2 - - - + + + 78 - - + + 79 - - + + 84 - - + + 85 - - + + 86 13 - - - - - + + + + + 76 0 - - - - - + + + + + 74 - - + + 75 7 - - - + + + 5 - - - + + + 8 - - - + + + 6 - - + + 9 - + 10 - + 11 - - - + + + 77 - - + + 82 12 - + 16 - - - + + + 80 - - + + 81 - - + + 83 @@ -2625,89 +2625,89 @@ - - - - + + + + 1 - - + + 3 - - + + 4 - - + + 2 - - - + + + 87 13 - - - - + + + + 5 - - - + + + 8 - - - + + + 6 - - + + 9 - + 10 - + 11 - + 12 - - - + + + 0 - - + + 7 - + 14 - + 16 @@ -2718,84 +2718,84 @@ - - - - + + + + 1 - - + + 3 - - + + 4 - - + + 2 - + 13 - - - - + + + + 5 - - - + + + 8 - - - + + + 6 - - + + 9 - + 10 - + 11 - + 12 - - - + + + 0 - - + + 7 - + 14 - + 16 @@ -2806,104 +2806,104 @@ - - - - + + + + 1 - - + + 3 - - + + 4 - - + + 2 - - - + + + 89 - - + + 90 13 - - - - + + + + 5 - - - + + + 8 - - - + + + 6 - - + + 9 - + 10 - + 11 - + 12 - - - + + + 0 - - - - + + + + 88 - - + + 91 7 - + 14 - + 16 @@ -2914,109 +2914,109 @@ - - - - + + + + 1 - - + + 3 - - + + 4 - - + + 2 - - - + + + 94 - - + + 95 - - + + 96 13 - - - - + + + + 5 - - - + + + 8 - - - + + + 6 - - + + 9 - + 10 - + 11 - + 12 - - - + + + 0 - - - - + + + + 92 7 - - - + + + 93 14 - + 16 @@ -3027,101 +3027,101 @@ - - - - + + + + 1 - - + + 3 - - + + 4 - - + + 2 - - - + + + 100 13 - - - - + + + + 5 - - - + + + 8 - - - + + + 6 - - + + 9 - + 10 - + 11 - + 12 - - - + + + 0 - - + + 7 - + 14 - - - + + + 97 - - + + 98 - - + + 99 @@ -3135,94 +3135,94 @@ - - - - + + + + 1 - - + + 3 - - - - + + + + 102 4 - - + + 2 - - - + + + 101 13 - - - - + + + + 5 - - - + + + 8 - - - + + + 6 - - + + 9 - + 10 - + 11 - + 12 - - - + + + 0 - - + + 7 - + 14 - + 16 @@ -3233,89 +3233,89 @@ - - - - + + + + 1 - - + + 3 - - + + 4 - - + + 2 - - - + + + 103 13 - - - - + + + + 5 - - - + + + 8 - - - + + + 6 - - + + 9 - + 10 - + 11 - + 12 - - - + + + 0 - - + + 7 - + 14 - + 16 diff --git a/python/tests/data/svg/ts_plain.svg b/python/tests/data/svg/ts_plain.svg index af9fde639a..b17eea874f 100644 --- a/python/tests/data/svg/ts_plain.svg +++ b/python/tests/data/svg/ts_plain.svg @@ -7,50 +7,50 @@ - - - - + + + + 0 - - + + 1 - + 4 - - - + + + 2 - - + + 3 - - - + + + 2 5 - - - + + + 0 - - + + 1 @@ -61,50 +61,50 @@ - - - - + + + + 0 - - + + 1 - - - + + + 3 - - + + 4 4 - - - + + + 2 - - + + 3 - + 5 - - - + + + 5 @@ -115,38 +115,38 @@ - - - - + + + + 0 - - + + 1 - + 4 - - - + + + 2 - - + + 3 - + 5 - + 6 @@ -154,38 +154,38 @@ - - - - + + + + 0 - - + + 1 - + 4 - - - + + + 2 - - + + 3 - + 5 - + 7 @@ -193,38 +193,38 @@ - - - - + + + + 0 - - + + 1 - + 4 - - - + + + 2 - - + + 3 - + 5 - + 8 diff --git a/python/tests/data/svg/ts_rank.svg b/python/tests/data/svg/ts_rank.svg new file mode 100644 index 0000000000..bdeaebde0f --- /dev/null +++ b/python/tests/data/svg/ts_rank.svg @@ -0,0 +1,297 @@ + + + + + + + + + + + + + + + + + + + + 0 + + + + + 1 + + + + 4 + + + + + + 2 + + + + + 3 + + + + + + 2 + + + 5 + + + + + + 0 + + + + + 1 + + + 9 + + + + + + + + + + + 0 + + + + + 1 + + + + + + 3 + + + + + 4 + + + 4 + + + + + + 2 + + + + + 3 + + + + 5 + + + + + + 5 + + + 7 + + + + + + + + + + + 0 + + + + + 1 + + + + 4 + + + + + + 2 + + + + + 3 + + + + 5 + + + + 6 + + + + + + + + + + + 0 + + + + + 1 + + + + 4 + + + + + + 2 + + + + + 3 + + + + 5 + + + + 7 + + + + + + + + + + + 0 + + + + + 1 + + + + 4 + + + + + + 2 + + + + + 3 + + + + 5 + + + + 8 + + + + + + + + + 0.00 + + + + 0.06 + + + + 0.79 + + + + 0.91 + + + + 0.91 + + + + 1.00 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/python/tests/data/svg/ts_xlabel.svg b/python/tests/data/svg/ts_xlabel.svg index 69a825d7e7..6306b4a827 100644 --- a/python/tests/data/svg/ts_xlabel.svg +++ b/python/tests/data/svg/ts_xlabel.svg @@ -5,57 +5,57 @@ - - - + + + - - - - + + + + 0 - - + + 1 - + 4 - - - + + + 2 - - + + 3 - - - + + + 2 5 - - - + + + 0 - - + + 1 @@ -66,50 +66,50 @@ - - - - + + + + 0 - - + + 1 - - - + + + 3 - - + + 4 4 - - - + + + 2 - - + + 3 - + 5 - - - + + + 5 @@ -120,38 +120,38 @@ - - - - + + + + 0 - - + + 1 - + 4 - - - + + + 2 - - + + 3 - + 5 - + 6 @@ -159,38 +159,38 @@ - - - - + + + + 0 - - + + 1 - + 4 - - - + + + 2 - - + + 3 - + 5 - + 7 @@ -198,38 +198,38 @@ - - - - + + + + 0 - - + + 1 - + 4 - - - + + + 2 - - + + 3 - + 5 - + 8 diff --git a/python/tests/test_drawing.py b/python/tests/test_drawing.py index 26dc9d568c..b0c84bce8f 100644 --- a/python/tests/test_drawing.py +++ b/python/tests/test_drawing.py @@ -151,7 +151,7 @@ def get_empty_tree(self): ts = tables.tree_sequence() return next(ts.trees()) - def get_simple_ts(self): + def get_simple_ts(self, use_mutation_times=False): """ return a simple tree seq that does not depend on msprime """ @@ -162,7 +162,7 @@ def get_simple_ts(self): 1 1 0 -1 0 2 1 0 -1 0 3 1 0 -1 0 - 4 0 0 -1 0.02445014598813 + 4 0 0 -1 0.1145014598813 5 0 0 -1 1.11067965364865 6 0 0 -1 1.75005250750382 7 0 0 -1 5.31067154311640 @@ -198,20 +198,23 @@ def get_simple_ts(self): 0.5 XXX """ ) - mutations = io.StringIO( + muts = io.StringIO( """\ - site node derived_state parent - 0 9 T -1 - 0 9 G 0 - 0 5 1 1 - 1 4 C -1 - 1 4 G 3 - 2 7 G -1 + site node derived_state parent time + 0 9 T -1 15 + 0 9 G 0 9.1 + 0 5 1 1 9 + 1 4 C -1 1.6 + 1 4 G 3 1.5 + 2 7 G -1 10 """ ) - return tskit.load_text( - nodes, edges, sites=sites, mutations=mutations, strict=False - ) + ts = tskit.load_text(nodes, edges, sites=sites, mutations=muts, strict=False) + if use_mutation_times: + return ts + tables = ts.dump_tables() + tables.mutations.time = np.full_like(tables.mutations.time, tskit.UNKNOWN_TIME) + return tables.tree_sequence() def fail(self, *args, **kwargs): """ @@ -1242,7 +1245,7 @@ def test_simple_tree_sequence(self): " ┊ ┃ ┃ ┊ ┃ ┃ ┊ ┏━┻━┓ ┊ ┃ ┃ ┊ ┃ ┃ ┊\n" "1.11┊ ┃ 5 ┊ ┃ 5 ┊ ┃ 5 ┊ ┃ 5 ┊ ┃ 5 ┊\n" " ┊ ┃ ┏┻┓ ┊ ┃ ┏┻┓ ┊ ┃ ┏┻┓ ┊ ┃ ┏┻┓ ┊ ┃ ┏┻┓ ┊\n" - "0.02┊ 4 ┃ ┃ ┊ 4 ┃ ┃ ┊ 4 ┃ ┃ ┊ 4 ┃ ┃ ┊ 4 ┃ ┃ ┊\n" + "0.11┊ 4 ┃ ┃ ┊ 4 ┃ ┃ ┊ 4 ┃ ┃ ┊ 4 ┃ ┃ ┊ 4 ┃ ┃ ┊\n" " ┊ ┏┻┓ ┃ ┃ ┊ ┏┻┓ ┃ ┃ ┊ ┏┻┓ ┃ ┃ ┊ ┏┻┓ ┃ ┃ ┊ ┏┻┓ ┃ ┃ ┊\n" "0.00┊ 0 1 2 3 ┊ 0 1 2 3 ┊ 0 1 2 3 ┊ 0 1 2 3 ┊ 0 1 2 3 ┊\n" " 0.00 0.06 0.79 0.91 0.91 1.00\n" @@ -1260,7 +1263,7 @@ def test_simple_tree_sequence(self): " | | | | | | | +-+-+ | | | | | | |\n" "1.11| | 5 | | 5 | | 5 | | 5 | | 5 |\n" " | | +++ | | +++ | | +++ | | +++ | | +++ |\n" - "0.02| 4 | | | 4 | | | 4 | | | 4 | | | 4 | | |\n" + "0.11| 4 | | | 4 | | | 4 | | | 4 | | | 4 | | |\n" " | +++ | | | +++ | | | +++ | | | +++ | | | +++ | | |\n" "0.00| 0 1 2 3 | 0 1 2 3 | 0 1 2 3 | 0 1 2 3 | 0 1 2 3 |\n" " 0.00 0.06 0.79 0.91 0.91 1.00\n" @@ -1849,7 +1852,7 @@ def test_no_edges(self): assert svg.count("rect") == 10 assert svg.count('path class="edge"') == 10 - # If there is a mutation, the root branches should be there too + # If there is a mutation above a sample, the root branches should be there too ts = msprime.mutate(ts, rate=1, random_seed=1) tables = ts.dump_tables() tables.edges.clear() @@ -1918,7 +1921,18 @@ def test_known_svg_tree_root_mut(self, overwrite_viz): svg = tree.draw_svg( root_svg_attributes={"id": "XYZ"}, style=".edge {stroke: blue}" ) - self.verify_known_svg(svg, "mut_tree.svg", overwrite_viz) + self.verify_known_svg(svg, "tree_muts.svg", overwrite_viz) + + def test_known_svg_tree_mut_no_edges(self, overwrite_viz): + ts = msprime.simulate(10, random_seed=2, mutation_rate=1) + tables = ts.dump_tables() + tables.edges.clear() + tree_no_edges = tables.tree_sequence().simplify().first() + svg = tree_no_edges.draw_svg() # A row of 10 sample nodes with root branches + self.verify_basic_svg(svg) + self.verify_known_svg( + svg, "tree_mutations_no_edges.svg", overwrite_viz, width=200 * ts.num_trees + ) def test_known_svg_ts(self, overwrite_viz): ts = self.get_simple_ts() @@ -1927,6 +1941,16 @@ def test_known_svg_ts(self, overwrite_viz): assert svg.count('class="mut ') == ts.num_mutations * 2 self.verify_known_svg(svg, "ts.svg", overwrite_viz, width=200 * ts.num_trees) + def test_known_svg_ts_internal_sample(self, overwrite_viz): + ts = tsutil.jiggle_samples(self.get_simple_ts()) + svg = ts.draw_svg( + root_svg_attributes={"id": "XYZ"}, + style="#XYZ .leaf .sym {fill: magenta} #XYZ .sample > .sym {fill: cyan}", + ) + self.verify_known_svg( + svg, "internal_sample_ts.svg", overwrite_viz, width=200 * ts.num_trees + ) + def test_known_svg_ts_highlighted_mut(self, overwrite_viz): ts = self.get_simple_ts() style = ( @@ -1941,6 +1965,18 @@ def test_known_svg_ts_highlighted_mut(self, overwrite_viz): svg, "ts_mut_highlight.svg", overwrite_viz, width=200 * ts.num_trees ) + def test_known_svg_ts_rank(self, overwrite_viz): + ts = self.get_simple_ts() + svg1 = ts.draw_svg(tree_height_scale="rank") + ts = self.get_simple_ts(use_mutation_times=True) + svg2 = ts.draw_svg(tree_height_scale="rank") + assert svg1 == svg2 # Must ignore mutation times if height is "rank" + assert svg1.count('class="site ') == ts.num_sites + assert svg1.count('class="mut ') == ts.num_mutations * 2 + self.verify_known_svg( + svg1, "ts_rank.svg", overwrite_viz, width=200 * ts.num_trees + ) + def test_known_svg_nonbinary_ts(self, overwrite_viz): ts = self.get_nonbinary_ts() svg = ts.draw_svg(tree_height_scale="log_time") @@ -1974,6 +2010,36 @@ def test_known_svg_ts_with_xlabel(self, overwrite_viz): svg, "ts_xlabel.svg", overwrite_viz, width=200 * ts.num_trees ) + def test_known_svg_ts_mutation_times(self, overwrite_viz): + ts = self.get_simple_ts(use_mutation_times=True) + svg = ts.draw_svg() + assert svg.count('class="site ') == ts.num_sites + assert svg.count('class="mut ') == ts.num_mutations * 2 + self.verify_known_svg( + svg, "ts_mut_times.svg", overwrite_viz, width=200 * ts.num_trees + ) + + def test_known_svg_ts_mutation_times_logscale(self, overwrite_viz): + ts = self.get_simple_ts(use_mutation_times=True) + svg = ts.draw_svg(tree_height_scale="log_time") + assert svg.count('class="site ') == ts.num_sites + assert svg.count('class="mut ') == ts.num_mutations * 2 + self.verify_known_svg( + svg, "ts_mut_times_logscale.svg", overwrite_viz, width=200 * ts.num_trees + ) + + def test_known_svg_ts_mut_no_edges(self, overwrite_viz, caplog): + ts = msprime.simulate(10, random_seed=2, mutation_rate=1) + tables = ts.dump_tables() + tables.edges.clear() + ts_no_edges = tables.tree_sequence() + svg = ts_no_edges.draw_svg() # Some muts on axis but not on a visible node + assert "not present in the displayed tree" in caplog.text + self.verify_basic_svg(svg) + self.verify_known_svg( + svg, "ts_mutations_no_edges.svg", overwrite_viz, width=200 * ts.num_trees + ) + class TestRounding: def test_rnd(self): diff --git a/python/tskit/drawing.py b/python/tskit/drawing.py index 195b5155b2..b1299b88a7 100644 --- a/python/tskit/drawing.py +++ b/python/tskit/drawing.py @@ -24,12 +24,15 @@ Module responsible for visualisations. """ import collections +import logging import math import numbers +import operator import numpy as np import svgwrite +import tskit.util as util from _tskit import NULL LEFT = "left" @@ -128,6 +131,16 @@ def rnd(x): return round(x, digits) +def identity(x): + return x + + +def log_transform(x): + # add 1 so that don't reach log(0) = -inf error. + # just shifts entire timeset by 1 unit so shouldn't affect anything + return np.log(x + 1) + + def add_text_in_group(dwg, elem, x, y, text, **kwargs): """ Add the text to the elem within a group. This allows text rotations to work smoothly @@ -265,7 +278,7 @@ def __init__( root_group = dwg.add(dwg.g(class_="tree-sequence")) if x_scale == "physical": background = root_group.add(dwg.g(class_="background")) - axis_top_pad = 20 + axis_top_pad = 15 tick_len = (0, 5) else: axis_top_pad = 5 @@ -347,6 +360,8 @@ def __init__( if x_scale == "treewise": x = tree_x elif x_scale == "physical": + # Shift diagonal lines between tree & axis into the treebox a little + backgd_pad_y = axis_top_pad + svg_trees[0].treebox_y_offset x = break_x if i > 0 and i % 2 == 1: # draw an alternating grey background @@ -355,15 +370,15 @@ def __init__( dwg.path( f"M{rnd(prev_tree_x):g},0 " f"l{rnd(tree_x-prev_tree_x):g},0 " - f"l0,{rnd(y - axis_top_pad):g} " - f"l{rnd(break_x-tree_x):g},{rnd(axis_top_pad):g} " + f"l0,{rnd(y - backgd_pad_y):g} " + f"l{rnd(break_x-tree_x):g},{rnd(backgd_pad_y):g} " # NB for curves try "c0,{1} {0},0 {0},{1}" instead of above f"l0,{rnd(tick_len[1]):g} " f"l{rnd(prev_break_x-break_x):g},0 " f"l0,{rnd(-tick_len[1]):g} " - f"l{rnd(prev_tree_x-prev_break_x):g},{rnd(-axis_top_pad):g} " + f"l{rnd(prev_tree_x-prev_break_x):g},{rnd(-backgd_pad_y):g} " # NB for curves try "c0,{1} {0},0 {0},{1}" instead of above - f"l0,{rnd(axis_top_pad - y):g}z", + f"l0,{rnd(backgd_pad_y - y):g}z", ) ) @@ -473,6 +488,7 @@ def __init__( symbol_size=None, ): self.tree = tree + self.ts = tree.tree_sequence self.traversal_order = check_order(order) if size is None: size = (200, 200) @@ -485,19 +501,27 @@ def __init__( self.symbol_size = symbol_size self.drawing = self.setup_drawing(style) self.node_mutations = collections.defaultdict(list) - self.mutations_over_root = False + self.mutations_over_roots = False + nodes = set(tree.nodes()) + unplotted = [] for site in tree.sites(): for mutation in site.mutations: - self.node_mutations[mutation.node].append(mutation) - if tree.parent(mutation.node) == NULL: - self.mutations_over_root = True + if mutation.node in nodes: + self.node_mutations[mutation.node].append(mutation) + if tree.parent(mutation.node) == NULL: + self.mutations_over_roots = True + else: + unplotted.append(mutation.id) + if len(unplotted) > 0: + logging.warning( + f"Mutations {unplotted} are above nodes which are not present in the " + "displayed tree, so are not plotted on the topology." + ) self.treebox_x_offset = 10 - self.treebox_y_offset = 10 + self.treebox_y_offset = 10 # Amount at top and bottom to leave blank self.treebox_width = size[0] - 2 * self.treebox_x_offset self.assign_y_coordinates(tree_height_scale, max_tree_height, force_root_branch) - self.node_x_coord_map = self.assign_x_coordinates( - tree, self.treebox_x_offset, self.treebox_width - ) + self.assign_x_coordinates(tree, self.treebox_x_offset, self.treebox_width) self.edge_attrs = {} self.node_attrs = {} self.node_label_attrs = {} @@ -566,65 +590,122 @@ def setup_drawing(self, style): self.root_group = dwg.add(dwg.g(class_=tree_class)) return dwg + def process_mutations(self, mut_node, lower_bound, upper_bound, ignore_times=False): + """ + Sort the self.node_mutations array for a given mut_node in reverse time order, + returning the oldest time. + The main complication is with UNKNOWN_TIME values: we replace those with + times spaced between the lower and upper bounds + """ + mutations = self.node_mutations[mut_node] + time_unknown = [util.is_unknown_time(m.time) for m in mutations] + if all(time_unknown) or ignore_times is True: + # sort by site then within site by parent: will end up with oldest first + mutations.sort(key=operator.attrgetter("site", "parent")) + diff = upper_bound - lower_bound + for i in range(len(mutations)): + mutations[i].time = upper_bound - diff * (i + 1) / (len(mutations) + 1) + else: + assert not any(time_unknown) + mutations.sort(key=operator.attrgetter("time"), reverse=True) + return mutations[0].time + def assign_y_coordinates( self, tree_height_scale, max_tree_height, force_root_branch ): tree_height_scale = check_tree_height_scale(tree_height_scale) + height = self.image_size[1] max_tree_height = check_max_tree_height( max_tree_height, tree_height_scale != "rank" ) - ts = self.tree.tree_sequence - node_time = ts.tables.nodes.time - + node_time = self.ts.tables.nodes.time + mut_time = self.ts.tables.mutations.time + transform = identity + self.min_root_branch_length = 0 if tree_height_scale == "rank": - assert tree_height_scale == "rank" if max_tree_height == "tree": # We only rank the times within the tree in this case. - t = np.zeros_like(node_time) + node_time[self.tree.left_root] + t = np.zeros_like(node_time) for u in self.tree.nodes(): t[u] = node_time[u] node_time = t depth = {t: 2 * j for j, t in enumerate(np.unique(node_time))} - node_height = [depth[node_time[u]] for u in range(ts.num_nodes)] - max_tree_height = max(depth.values()) + if self.mutations_over_roots or force_root_branch: + self.min_root_branch_length = 2 # Will get scaled later + max_tree_height = max(depth.values()) + self.min_root_branch_length + # In pathological cases, all the roots are at 0 + if max_tree_height == 0: + max_tree_height = 1 + node_height = {u: depth[node_time[u]] for u in self.tree.nodes()} + for u in self.node_mutations.keys(): + parent = self.tree.parent(u) + if parent == NULL: + top = node_height[u] + self.min_root_branch_length + else: + top = node_height[parent] + self.process_mutations(u, node_height[u], top, ignore_times=True) else: assert tree_height_scale in ["time", "log_time"] + node_height = {u: node_time[u] for u in self.tree.nodes()} if max_tree_height == "tree": - max_tree_height = max(self.tree.time(root) for root in self.tree.roots) - elif max_tree_height == "ts": - max_tree_height = ts.max_root_time + max_node_height = max(node_height.values()) + max_mut_height = np.nanmax( + [0] + [mut.time for m in self.node_mutations.values() for mut in m] + ) + else: + max_node_height = np.max(node_time) + max_mut_height = np.nanmax(np.append(mut_time, 0)) + max_tree_height = max(max_node_height, max_mut_height) # Reuse variable + # In pathological cases, all the roots are at 0 + if max_tree_height == 0: + max_tree_height = 1 + + if self.mutations_over_roots or force_root_branch: + # TODO - what should the minimum root branch length be in this case? We + # take an eighth of the oldest time. This may be made longer by old muts + self.min_root_branch_length = max_tree_height / 8 + # May need to allow for this in max_tree_height + if max_node_height + self.min_root_branch_length > max_tree_height: + max_tree_height = max_node_height + self.min_root_branch_length + for u in self.node_mutations.keys(): + parent = self.tree.parent(u) + if parent == NULL: + # This is a root: if muts have no times we must specify an upper time + top = node_height[u] + self.min_root_branch_length + else: + top = node_height[parent] + self.process_mutations(u, node_height[u], top) if tree_height_scale == "log_time": - # add 1 so that don't reach log(0) = -inf error. - # just shifts entire timeset by 1 year so shouldn't affect anything - node_height = np.log(ts.tables.nodes.time + 1) - elif tree_height_scale == "time": - node_height = node_time + transform = log_transform assert float(max_tree_height) == max_tree_height - # In pathological cases, all the roots are at 0 - if max_tree_height == 0: - max_tree_height = 1 - - # TODO should make this a parameter somewhere. This is padding to keep the - # node labels within the treebox - label_padding = 6 - y_padding = self.treebox_y_offset + 2 * label_padding + # TODO should make this a parameter somewhere. This is padding above the top and + # below the bottom of the tree to keep the node labels within the treebox. Top is + # not needed if we have a root branch which pushes the whole tree + labels down + top_label_pad = 0 if self.min_root_branch_length > 0 else 18 + bottom_label_pad = 18 + y_top = top_label_pad + self.treebox_y_offset height = self.image_size[1] - self.root_branch_length = 0 - if self.mutations_over_root or force_root_branch: - self.root_branch_length = height / 10 # FIXME what scaling to use? - # y scaling - padding_numerator = height - self.root_branch_length - 2 * y_padding - if tree_height_scale == "log_time": - # again shift time by 1 in log(max_tree_height), so consistent - y_scale = padding_numerator / (np.log(max_tree_height + 1)) - else: - y_scale = padding_numerator / max_tree_height - self.node_y_coord_map = [ - height - y_scale * node_height[u] - y_padding for u in range(ts.num_nodes) - ] + padding_numerator = height - y_top - bottom_label_pad - self.treebox_y_offset + y_scale = padding_numerator / transform(max_tree_height) + max_node = max(node_height.keys(), key=node_height.get) + # Transform the y values into plot space (inverted y with 0 at the top of screen) + node_height["root_branch"] = node_height[max_node] + self.min_root_branch_length + self.node_y_coord_map = { + u: y_top + (padding_numerator - y_scale * transform(h)) + for u, h in node_height.items() + } + self.mut_y_coord_map = { + m.id: y_top + (padding_numerator - y_scale * transform(m.time)) + for _, mutations in self.node_mutations.items() + for m in mutations + } + self.min_root_branch_length = ( + self.node_y_coord_map[max_node] - self.node_y_coord_map["root_branch"] + ) + # Here we could also define and transform the tickmarks on the Y axis if required def assign_x_coordinates(self, tree, x_start, width): num_leaves = len(list(tree.leaves())) @@ -644,7 +725,7 @@ def assign_x_coordinates(self, tree, x_start, width): a = min(child_coords) b = max(child_coords) node_x_coord_map[u] = a + (b - a) / 2 - return node_x_coord_map + self.node_x_coord_map = node_x_coord_map def info_classes(self, focal_node_id): """ @@ -655,7 +736,7 @@ def info_classes(self, focal_node_id): "s": where == site id of all mutations """ # Add a new group for each node, and give it classes for css targetting - focal_node = self.tree.tree_sequence.node(focal_node_id) + focal_node = self.ts.node(focal_node_id) classes = set() classes.add(f"node n{focal_node_id}") if focal_node.individual != NULL: @@ -722,22 +803,30 @@ def draw(self): ) curr_svg_group.add(path) else: - if self.root_branch_length > 0: + branch_length = self.min_root_branch_length + if branch_length > 0: self.add_class(self.edge_attrs[u], "edge") + if len(self.node_mutations[u]) > 0: + mutation = self.node_mutations[u][ + 0 + ] # Oldest mut on this branch + branch_length = max( + branch_length, + self.node_y_coord_map[u] + - self.mut_y_coord_map[mutation.id], + ) path = dwg.path( - [("M", o), ("V", rnd(-self.root_branch_length)), ("H", 0)], + [("M", o), ("V", rnd(-branch_length)), ("H", 0)], **self.edge_attrs[u], ) curr_svg_group.add(path) - pv = (pu[0], pu[1] - self.root_branch_length) + pv = (pu[0], pu[1] - branch_length) # Add mutation symbols + labels - num_muts = len(self.node_mutations[u]) - delta = (pv[1] - pu[1]) / (num_muts + 1) - for i, mutation in enumerate(self.node_mutations[u]): + for mutation in self.node_mutations[u]: # TODO get rid of these manual positioning tweaks and add them # as offsets the user can access via a transform or something. - dy = (num_muts - i) * delta + dy = self.mut_y_coord_map[mutation.id] - pu[1] mutation_class = f"mut m{mutation.id} s{mutation.site}" mut_group = curr_svg_group.add( dwg.g(class_=mutation_class, transform=f"translate(0 {rnd(dy)})") @@ -748,7 +837,7 @@ def draw(self): # Symbols mut_group.add(dwg.path(**self.mutation_attrs[mutation.id])) # Labels - if mutation.node == left_child[tree.parent(mutation.node)]: + if u == left_child[tree.parent(u)]: mut_label_class = "lft" transform = "translate(-5 0)" else: @@ -767,7 +856,7 @@ def draw(self): # Labels if tree.is_leaf(u): self.node_label_attrs[u]["transform"] = "translate(0 12)" - elif tree.parent(u) == NULL and self.root_branch_length == 0: + elif tree.parent(u) == NULL and self.min_root_branch_length == 0: self.node_label_attrs[u]["transform"] = "translate(0 -10)" else: if u == left_child[tree.parent(u)]: From 74f008fe8824a72489462385ad5e2c377155cc60 Mon Sep 17 00:00:00 2001 From: Yan Wong Date: Wed, 3 Mar 2021 15:20:34 +0000 Subject: [PATCH 2/5] Add a WF multiroot SVG example --- python/tests/data/svg/ts_multiroot.svg | 495 +++++++++++++++++++++++++ python/tests/test_drawing.py | 19 +- 2 files changed, 513 insertions(+), 1 deletion(-) create mode 100644 python/tests/data/svg/ts_multiroot.svg diff --git a/python/tests/data/svg/ts_multiroot.svg b/python/tests/data/svg/ts_multiroot.svg new file mode 100644 index 0000000000..d2ee06ed78 --- /dev/null +++ b/python/tests/data/svg/ts_multiroot.svg @@ -0,0 +1,495 @@ + + + + + + + + + + + + + + + + + + 0 + + + + + + + 2 + + + + + 4 + + + + + 5 + + + + + + 0 + + + 6 + + + + + + 1 + + + + + 3 + + + + 11 + + + 15 + + + + + + + + + + 2 + + + + + 4 + + + + + 5 + + + 6 + + + + + + 0 + + + + + + 1 + + + + + 3 + + + + 11 + + + 12 + + + + + + + + 0 + + + + + + + 2 + + + + + 4 + + + + + + + 1 + + + 5 + + + + + + 2 + + + 6 + + + + + + 1 + + + + + 3 + + + + 11 + + + 14 + + + + + + + + 0 + + + + + + + + + 4 + + + 2 + + + + + + + 3 + + + 4 + + + + + 5 + + + + 6 + + + + + + 1 + + + + + 3 + + + + 7 + + + 14 + + + + + + + + + + 1 + + + + + 3 + + + 7 + + + + + + 0 + + + + + + 5 + + + + + + 2 + + + + + 4 + + + + 6 + + + + 10 + + + 13 + + + + + + + + + + + + 5 + + + 2 + + + + + 4 + + + 6 + + + + + + 1 + + + + + 3 + + + 7 + + + + + + 0 + + + + + 5 + + + 13 + + + + + + + + + + 2 + + + + + 4 + + + + + 3 + + + 6 + + + + 1 + + + + + + 0 + + + + + 5 + + + 8 + + + + + + + + + + 2 + + + + + 4 + + + + + 3 + + + 6 + + + + + + 1 + + + + + + 0 + + + + + 5 + + + + 8 + + + 9 + + + + + + + + + 0 + + + + 1 + + + + 2 + + + + 3 + + + + 4 + + + + 5 + + + + 6 + + + + 7 + + + + 8 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/python/tests/test_drawing.py b/python/tests/test_drawing.py index b0c84bce8f..398e9dfacb 100644 --- a/python/tests/test_drawing.py +++ b/python/tests/test_drawing.py @@ -38,6 +38,7 @@ import pytest import xmlunittest +import tests.test_wright_fisher as wf import tests.tsutil as tsutil import tskit from tskit import drawing @@ -2035,11 +2036,27 @@ def test_known_svg_ts_mut_no_edges(self, overwrite_viz, caplog): ts_no_edges = tables.tree_sequence() svg = ts_no_edges.draw_svg() # Some muts on axis but not on a visible node assert "not present in the displayed tree" in caplog.text - self.verify_basic_svg(svg) self.verify_known_svg( svg, "ts_mutations_no_edges.svg", overwrite_viz, width=200 * ts.num_trees ) + def test_known_svg_ts_multiroot(self, overwrite_viz, caplog): + tables = wf.wf_sim( + 6, + 5, + seed=1, + deep_history=False, + initial_generation_samples=False, + num_loci=8, + ) + tables.sort() + ts = tables.tree_sequence().simplify() + ts = msprime.mutate(ts, rate=0.1, random_seed=123) + svg = ts.draw_svg() + self.verify_known_svg( + svg, "ts_multiroot.svg", overwrite_viz, width=200 * ts.num_trees + ) + class TestRounding: def test_rnd(self): From 4e96806a2c42c65ef69ead1d0f22308624abab38 Mon Sep 17 00:00:00 2001 From: Yan Wong Date: Wed, 3 Mar 2021 15:20:51 +0000 Subject: [PATCH 3/5] correct docstring spellings --- python/tests/tsutil.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/tests/tsutil.py b/python/tests/tsutil.py index 9c012c03f6..f78463b1b6 100644 --- a/python/tests/tsutil.py +++ b/python/tests/tsutil.py @@ -526,7 +526,7 @@ def generate_site_mutations( def jukes_cantor(ts, num_sites, mu, multiple_per_node=True, seed=None): """ Returns a copy of the specified tree sequence with Jukes-Cantor mutations - applied at the specfied rate at the specifed number of sites. Site positions + applied at the specified rate at the specified number of sites. Site positions are chosen uniformly. """ random.seed(seed) From df4a6273d1d9942446567ffa30bf0d1f6b781e31 Mon Sep 17 00:00:00 2001 From: Yan Wong Date: Tue, 9 Mar 2021 18:05:17 +0000 Subject: [PATCH 4/5] Major reworking of SVG drawing code Allows X and Y axes on both trees and tree sequences --- python/CHANGELOG.rst | 4 + python/tests/conftest.py | 11 + python/tests/data/svg/internal_sample_ts.svg | 439 +- python/tests/data/svg/tree.svg | 62 +- python/tests/data/svg/tree_both_axes.svg | 94 + .../data/svg/tree_mutations_no_edges.svg | 68 - python/tests/data/svg/tree_muts.svg | 90 +- python/tests/data/svg/tree_timed_muts.svg | 60 + python/tests/data/svg/tree_x_axis.svg | 98 + python/tests/data/svg/tree_y_axis.svg | 96 + python/tests/data/svg/ts.svg | 397 +- python/tests/data/svg/ts_multiroot.svg | 648 +- python/tests/data/svg/ts_mut_highlight.svg | 397 +- python/tests/data/svg/ts_mut_times.svg | 397 +- .../tests/data/svg/ts_mut_times_logscale.svg | 397 +- .../tests/data/svg/ts_mutations_no_edges.svg | 157 +- .../data/svg/ts_mutations_timed_no_edges.svg | 121 + python/tests/data/svg/ts_no_axes.svg | 235 + python/tests/data/svg/ts_nonbinary.svg | 5343 +++++++++-------- python/tests/data/svg/ts_plain.svg | 329 +- python/tests/data/svg/ts_plain_no_xlab.svg | 276 + python/tests/data/svg/ts_plain_y.svg | 306 + python/tests/data/svg/ts_rank.svg | 445 +- python/tests/data/svg/ts_xlabel.svg | 400 +- python/tests/data/svg/ts_y_axis.svg | 364 ++ python/tests/data/svg/ts_y_axis_log.svg | 364 ++ python/tests/data/svg/ts_y_axis_regular.svg | 392 ++ python/tests/test_drawing.py | 377 +- python/tskit/drawing.py | 1026 ++-- python/tskit/trees.py | 64 +- 30 files changed, 8406 insertions(+), 5051 deletions(-) create mode 100644 python/tests/data/svg/tree_both_axes.svg delete mode 100644 python/tests/data/svg/tree_mutations_no_edges.svg create mode 100644 python/tests/data/svg/tree_timed_muts.svg create mode 100644 python/tests/data/svg/tree_x_axis.svg create mode 100644 python/tests/data/svg/tree_y_axis.svg create mode 100644 python/tests/data/svg/ts_mutations_timed_no_edges.svg create mode 100644 python/tests/data/svg/ts_no_axes.svg create mode 100644 python/tests/data/svg/ts_plain_no_xlab.svg create mode 100644 python/tests/data/svg/ts_plain_y.svg create mode 100644 python/tests/data/svg/ts_y_axis.svg create mode 100644 python/tests/data/svg/ts_y_axis_log.svg create mode 100644 python/tests/data/svg/ts_y_axis_regular.svg diff --git a/python/CHANGELOG.rst b/python/CHANGELOG.rst index 9f82b20273..1fcca833f5 100644 --- a/python/CHANGELOG.rst +++ b/python/CHANGELOG.rst @@ -4,6 +4,10 @@ **Features** +- SVG visualization plots mutations at the correct time, if it exists, and a y-axis, + with label can be drawn. Both x- and y-axes can be plotted on trees as well as + tree sequences (:user:`hyanwong`,:issue:`840`, :issue:`580`, :pr:`1236`) + - SVG visualization now uses squares for sample nodes and red crosses for mutations, with the site/mutation positions marked on the x-axis. Additionally, an x-axis label can be set (:user:`hyanwong`,:issue:`1155`, :issue:`1194`, :pr:`1182`, :pr:`1213`) diff --git a/python/tests/conftest.py b/python/tests/conftest.py index 699be84352..cdc3ddf827 100644 --- a/python/tests/conftest.py +++ b/python/tests/conftest.py @@ -58,6 +58,12 @@ def pytest_addoption(parser): default=False, help="Overwrite the expected viz files in tests/data/svg/", ) + parser.addoption( + "--draw-svg-debug-box", + action="store_true", + default=False, + help="To help debugging, draw lines around the plotboxes in SVG output files", + ) def pytest_configure(config): @@ -80,6 +86,11 @@ def overwrite_viz(request): return request.config.getoption("--overwrite-expected-visualizations") +@fixture +def draw_plotbox(request): + return request.config.getoption("--draw-svg-debug-box") + + @fixture(scope="session") def simple_ts_fixture(): return msprime.simulate(10, random_seed=42) diff --git a/python/tests/data/svg/internal_sample_ts.svg b/python/tests/data/svg/internal_sample_ts.svg index 121dfdb188..4db49d6830 100644 --- a/python/tests/data/svg/internal_sample_ts.svg +++ b/python/tests/data/svg/internal_sample_ts.svg @@ -1,332 +1,351 @@ - + - - - + + + + + - - - - - - - + + + + Genome position + + + + + + 0.00 + + + + + + 0.06 + + + + + + 0.79 + + + + + + 0.91 + + + + + + 0.91 + + + + + + 1.00 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - 0 + 0 - - + + - 1 + 1 - + - 4 + 4 - - - + + + - 2 + 2 - - + + - 3 + 3 - - - + + + 2 - 5 + 5 - - - + + + 0 - - + + 1 - 9 + 9 - - + + - 7 + 7 - - + + - 8 + 8 - - - - - - + + + + + + - 0 + 0 - - + + - 1 + 1 - - - + + + 3 - - + + 4 - 4 + 4 - - - + + + - 2 + 2 - - + + - 3 + 3 - + - 5 + 5 - - - + + + 5 - 7 + 7 - - + + - 8 + 8 - - - - - - + + + + + + - 0 + 0 - - + + - 1 + 1 - + - 4 + 4 - - - + + + - 2 + 2 - - + + - 3 + 3 - + - 5 + 5 - + - 6 + 6 - - + + - 7 + 7 - - + + - 8 + 8 - - - - - - + + + + + + - 0 + 0 - - + + - 1 + 1 - + - 4 + 4 - - - + + + - 2 + 2 - - + + - 3 + 3 - + - 5 + 5 - + - 7 + 7 - - + + - 8 + 8 - - - - + + + + - 7 + 7 - - - - + + + + - 0 + 0 - - + + - 1 + 1 - + - 4 + 4 - - - + + + - 2 + 2 - - + + - 3 + 3 - + - 5 + 5 - + - 8 + 8 - - - - - 0.00 - - - - 0.06 - - - - 0.79 - - - - 0.91 - - - - 0.91 - - - - 1.00 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/python/tests/data/svg/tree.svg b/python/tests/data/svg/tree.svg index efd2a89fc3..ca28992362 100644 --- a/python/tests/data/svg/tree.svg +++ b/python/tests/data/svg/tree.svg @@ -1,42 +1,44 @@ - + - - - - - - 0 + + + + + + + 0 + + + + + 1 + + + + 4 - - - - 1 + + + + + 2 + + + + + 3 + + + + 5 - - 4 + 8 - - - - - 2 - - - - - 3 - - - - 5 - - - 8 diff --git a/python/tests/data/svg/tree_both_axes.svg b/python/tests/data/svg/tree_both_axes.svg new file mode 100644 index 0000000000..2ccad3904a --- /dev/null +++ b/python/tests/data/svg/tree_both_axes.svg @@ -0,0 +1,94 @@ + + + + + + + + + + Genome position + + + + + + 0.91 + + + + + + 1.00 + + + + + + Time + + + + + + 0.11 + + + + + + 0.00 + + + + + + 1.11 + + + + + + 6.57 + + + + + + + + + + + 0 + + + + + 1 + + + + 4 + + + + + + 2 + + + + + 3 + + + + 5 + + + 8 + + + + diff --git a/python/tests/data/svg/tree_mutations_no_edges.svg b/python/tests/data/svg/tree_mutations_no_edges.svg deleted file mode 100644 index 407a2f7e4c..0000000000 --- a/python/tests/data/svg/tree_mutations_no_edges.svg +++ /dev/null @@ -1,68 +0,0 @@ - - - - - - - - - - 0 - - - - - 1 - - - - - 2 - - - - - 3 - - - - - 4 - - - - - - - 0 - - - 5 - - - - - 6 - - - - - - - 1 - - - 7 - - - - - 8 - - - - - 9 - - - diff --git a/python/tests/data/svg/tree_muts.svg b/python/tests/data/svg/tree_muts.svg index cecfef18ca..a67d60cbcb 100644 --- a/python/tests/data/svg/tree_muts.svg +++ b/python/tests/data/svg/tree_muts.svg @@ -1,58 +1,60 @@ - + - + - - - - - - 0 + + + + + + + 0 + + + + + 1 + + + + 4 - - - - 1 + + + + + 2 + + + + + 3 + + + + + + 2 + + + 5 - - - 4 - - - - - - 2 - - - - - 3 + + + + + 0 - - - + + - 2 + 1 - 5 - - - - - - 0 - - - - - 1 + 9 - - 9 diff --git a/python/tests/data/svg/tree_timed_muts.svg b/python/tests/data/svg/tree_timed_muts.svg new file mode 100644 index 0000000000..da878b24b7 --- /dev/null +++ b/python/tests/data/svg/tree_timed_muts.svg @@ -0,0 +1,60 @@ + + + + + + + + + + + + + 0 + + + + + 1 + + + + 4 + + + + + + 2 + + + + + 3 + + + + + + 2 + + + 5 + + + + + + 0 + + + + + 1 + + + 9 + + + + diff --git a/python/tests/data/svg/tree_x_axis.svg b/python/tests/data/svg/tree_x_axis.svg new file mode 100644 index 0000000000..0bf522b335 --- /dev/null +++ b/python/tests/data/svg/tree_x_axis.svg @@ -0,0 +1,98 @@ + + + + + + + + + + pos on genome + + + + + + 0.06 + + + + + + 0.79 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + + + + + 1 + + + + + + 3 + + + + + 4 + + + 4 + + + + + + 2 + + + + + 3 + + + + 5 + + + + + + 5 + + + 7 + + + + diff --git a/python/tests/data/svg/tree_y_axis.svg b/python/tests/data/svg/tree_y_axis.svg new file mode 100644 index 0000000000..e37b7fdf08 --- /dev/null +++ b/python/tests/data/svg/tree_y_axis.svg @@ -0,0 +1,96 @@ + + + + + + + + + + Time (relative steps) + + + + + + + 0.00 + + + + + + + 1.00 + + + + + + + 2.00 + + + + + + + 3.00 + + + + + + + + + + + 0 + + + + + 1 + + + + + + 3 + + + + + 4 + + + 4 + + + + + + 2 + + + + + 3 + + + + 5 + + + + + + 5 + + + 7 + + + + diff --git a/python/tests/data/svg/ts.svg b/python/tests/data/svg/ts.svg index aec24a6bc9..3eed7e0685 100644 --- a/python/tests/data/svg/ts.svg +++ b/python/tests/data/svg/ts.svg @@ -1,297 +1,316 @@ - + - - - + + + + + - - - - - - - + + + + Genome position + + + + + + 0.00 + + + + + + 0.06 + + + + + + 0.79 + + + + + + 0.91 + + + + + + 0.91 + + + + + + 1.00 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - 0 + 0 - - + + - 1 + 1 - + - 4 + 4 - - - + + + - 2 + 2 - - + + - 3 + 3 - - - + + + 2 - 5 + 5 - - - + + + 0 - - + + 1 - 9 + 9 - - - - - - + + + + + + - 0 + 0 - - + + - 1 + 1 - - - + + + 3 - - + + 4 - 4 + 4 - - - + + + - 2 + 2 - - + + - 3 + 3 - + - 5 + 5 - - - + + + 5 - 7 + 7 - - - - - - + + + + + + - 0 + 0 - - + + - 1 + 1 - + - 4 + 4 - - - + + + - 2 + 2 - - + + - 3 + 3 - + - 5 + 5 - + - 6 + 6 - - - - - - + + + + + + - 0 + 0 - - + + - 1 + 1 - + - 4 + 4 - - - + + + - 2 + 2 - - + + - 3 + 3 - + - 5 + 5 - + - 7 + 7 - - - - - - + + + + + + - 0 + 0 - - + + - 1 + 1 - + - 4 + 4 - - - + + + - 2 + 2 - - + + - 3 + 3 - + - 5 + 5 - + - 8 + 8 - - - - - 0.00 - - - - 0.06 - - - - 0.79 - - - - 0.91 - - - - 0.91 - - - - 1.00 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/python/tests/data/svg/ts_multiroot.svg b/python/tests/data/svg/ts_multiroot.svg index d2ee06ed78..37b0aa01c4 100644 --- a/python/tests/data/svg/ts_multiroot.svg +++ b/python/tests/data/svg/ts_multiroot.svg @@ -1,495 +1,527 @@ - + - - - - + + + + + + + + - - - - + + + + Genome position + + + + + + 0 + + + + + + 1 + + + + + + 2 + + + + + + 3 + + + + + + 4 + + + + + + 5 + + + + + + 6 + + + + + + 7 + + + + + + 8 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Time (WF gens) + + + + + + + - 0 + 0 - - - - + + + + - 2 + 2 - - + + - 4 + 4 - - + + - 5 + 5 - - - + + + 0 - 6 + 6 - - - + + + - 1 + 1 - - + + - 3 + 3 - + - 11 + 11 - 15 + 15 - - - - - + + + + + - 2 + 2 - - + + - 4 + 4 - - + + - 5 + 5 - 6 + 6 - - - + + + - 0 + 0 - - - + + + - 1 + 1 - - + + - 3 + 3 - + - 11 + 11 - 12 + 12 - - - + + + - 0 + 0 - - - - + + + + - 2 + 2 - - + + - 4 + 4 - - - - + + + + 1 - 5 + 5 - - - + + + 2 - 6 + 6 - - - + + + - 1 + 1 - - + + - 3 + 3 - + - 11 + 11 - 14 + 14 - - - + + + - 0 - - - - - - - + 0 + + + + + + + 4 - 2 + 2 - - - - + + + + 3 - 4 + 4 - - + + - 5 + 5 - + - 6 + 6 - - - + + + - 1 + 1 - - + + - 3 + 3 - + - 7 + 7 - 14 + 14 - - - - - + + + + + - 1 + 1 - - + + - 3 + 3 - 7 + 7 - - - + + + - 0 + 0 - - - + + + - 5 + 5 - - - + + + - 2 + 2 - - + + - 4 + 4 - + - 6 + 6 - + - 10 + 10 - 13 + 13 - - - - - - - + + + + + + + 5 - 2 + 2 - - + + - 4 + 4 - 6 + 6 - - - + + + - 1 + 1 - - + + - 3 + 3 - 7 + 7 - - - + + + - 0 + 0 - - + + - 5 + 5 - 13 + 13 - - - - - + + + + + - 2 + 2 - - + + - 4 + 4 - - + + - 3 + 3 - 6 + 6 - + - 1 + 1 - - - + + + - 0 + 0 - - + + - 5 + 5 - 8 + 8 - - - - - + + + + + - 2 + 2 - - + + - 4 + 4 - - + + - 3 + 3 - 6 + 6 - - - + + + - 1 + 1 - - - + + + - 0 + 0 - - + + - 5 + 5 - + - 8 + 8 - 9 + 9 - - - - - 0 - - - - 1 - - - - 2 - - - - 3 - - - - 4 - - - - 5 - - - - 6 - - - - 7 - - - - 8 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/python/tests/data/svg/ts_mut_highlight.svg b/python/tests/data/svg/ts_mut_highlight.svg index 45b726efc2..c7beab410e 100644 --- a/python/tests/data/svg/ts_mut_highlight.svg +++ b/python/tests/data/svg/ts_mut_highlight.svg @@ -1,297 +1,316 @@ - + - - - + + + + + - - - - - - - + + + + Genome position + + + + + + 0.00 + + + + + + 0.06 + + + + + + 0.79 + + + + + + 0.91 + + + + + + 0.91 + + + + + + 1.00 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - 0 + 0 - - + + - 1 + 1 - + - 4 + 4 - - - + + + - 2 + 2 - - + + - 3 + 3 - - - + + + 2 - 5 + 5 - - - + + + 0 - - + + 1 - 9 + 9 - - - - - - + + + + + + - 0 + 0 - - + + - 1 + 1 - - - + + + 3 - - + + 4 - 4 + 4 - - - + + + - 2 + 2 - - + + - 3 + 3 - + - 5 + 5 - - - + + + 5 - 7 + 7 - - - - - - + + + + + + - 0 + 0 - - + + - 1 + 1 - + - 4 + 4 - - - + + + - 2 + 2 - - + + - 3 + 3 - + - 5 + 5 - + - 6 + 6 - - - - - - + + + + + + - 0 + 0 - - + + - 1 + 1 - + - 4 + 4 - - - + + + - 2 + 2 - - + + - 3 + 3 - + - 5 + 5 - + - 7 + 7 - - - - - - + + + + + + - 0 + 0 - - + + - 1 + 1 - + - 4 + 4 - - - + + + - 2 + 2 - - + + - 3 + 3 - + - 5 + 5 - + - 8 + 8 - - - - - 0.00 - - - - 0.06 - - - - 0.79 - - - - 0.91 - - - - 0.91 - - - - 1.00 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/python/tests/data/svg/ts_mut_times.svg b/python/tests/data/svg/ts_mut_times.svg index 133cd86178..064b278be0 100644 --- a/python/tests/data/svg/ts_mut_times.svg +++ b/python/tests/data/svg/ts_mut_times.svg @@ -1,297 +1,316 @@ - + - - - + + + + + - - - - - - - + + + + Genome position + + + + + + 0.00 + + + + + + 0.06 + + + + + + 0.79 + + + + + + 0.91 + + + + + + 0.91 + + + + + + 1.00 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - 0 + 0 - - + + - 1 + 1 - + - 4 + 4 - - - + + + - 2 + 2 - - + + - 3 + 3 - - - + + + 2 - 5 + 5 - - - + + + 0 - - + + 1 - 9 + 9 - - - - - - + + + + + + - 0 + 0 - - + + - 1 + 1 - - - + + + 3 - - + + 4 - 4 + 4 - - - + + + - 2 + 2 - - + + - 3 + 3 - + - 5 + 5 - - - + + + 5 - 7 + 7 - - - - - - + + + + + + - 0 + 0 - - + + - 1 + 1 - + - 4 + 4 - - - + + + - 2 + 2 - - + + - 3 + 3 - + - 5 + 5 - + - 6 + 6 - - - - - - + + + + + + - 0 + 0 - - + + - 1 + 1 - + - 4 + 4 - - - + + + - 2 + 2 - - + + - 3 + 3 - + - 5 + 5 - + - 7 + 7 - - - - - - + + + + + + - 0 + 0 - - + + - 1 + 1 - + - 4 + 4 - - - + + + - 2 + 2 - - + + - 3 + 3 - + - 5 + 5 - + - 8 + 8 - - - - - 0.00 - - - - 0.06 - - - - 0.79 - - - - 0.91 - - - - 0.91 - - - - 1.00 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/python/tests/data/svg/ts_mut_times_logscale.svg b/python/tests/data/svg/ts_mut_times_logscale.svg index 77a841e90c..6b05dbe881 100644 --- a/python/tests/data/svg/ts_mut_times_logscale.svg +++ b/python/tests/data/svg/ts_mut_times_logscale.svg @@ -1,297 +1,316 @@ - + - - - + + + + + - - - - - - - + + + + Genome position + + + + + + 0.00 + + + + + + 0.06 + + + + + + 0.79 + + + + + + 0.91 + + + + + + 0.91 + + + + + + 1.00 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - 0 + 0 - - + + - 1 + 1 - + - 4 + 4 - - - + + + - 2 + 2 - - + + - 3 + 3 - - - + + + 2 - 5 + 5 - - - + + + 0 - - + + 1 - 9 + 9 - - - - - - + + + + + + - 0 + 0 - - + + - 1 + 1 - - - + + + 3 - - + + 4 - 4 + 4 - - - + + + - 2 + 2 - - + + - 3 + 3 - + - 5 + 5 - - - + + + 5 - 7 + 7 - - - - - - + + + + + + - 0 + 0 - - + + - 1 + 1 - + - 4 + 4 - - - + + + - 2 + 2 - - + + - 3 + 3 - + - 5 + 5 - + - 6 + 6 - - - - - - + + + + + + - 0 + 0 - - + + - 1 + 1 - + - 4 + 4 - - - + + + - 2 + 2 - - + + - 3 + 3 - + - 5 + 5 - + - 7 + 7 - - - - - - + + + + + + - 0 + 0 - - + + - 1 + 1 - + - 4 + 4 - - - + + + - 2 + 2 - - + + - 3 + 3 - + - 5 + 5 - + - 8 + 8 - - - - - 0.00 - - - - 0.06 - - - - 0.79 - - - - 0.91 - - - - 0.91 - - - - 1.00 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/python/tests/data/svg/ts_mutations_no_edges.svg b/python/tests/data/svg/ts_mutations_no_edges.svg index 92caa47e64..ca5236eaa0 100644 --- a/python/tests/data/svg/ts_mutations_no_edges.svg +++ b/python/tests/data/svg/ts_mutations_no_edges.svg @@ -1,112 +1,121 @@ - + - + - - - - - + + + + Genome position + + + + + + 0 + + + + + + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - 0 + 0 - - + + - 1 + 1 - - + + - 2 + 2 - - + + - 3 + 3 - - + + - 4 + 4 - - - - + + + + 0 - 5 + 5 - - + + - 6 + 6 - - - - + + + + 3 - 7 + 7 - - + + - 8 + 8 - - + + - 9 + 9 - - - - - 0 - - - - 1 - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/python/tests/data/svg/ts_mutations_timed_no_edges.svg b/python/tests/data/svg/ts_mutations_timed_no_edges.svg new file mode 100644 index 0000000000..a05fa76538 --- /dev/null +++ b/python/tests/data/svg/ts_mutations_timed_no_edges.svg @@ -0,0 +1,121 @@ + + + + + + + + + + + + + Genome position + + + + + + 0 + + + + + + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + + + + + 1 + + + + + 2 + + + + + 3 + + + + + 4 + + + + + + + 0 + + + 5 + + + + + 6 + + + + + + + 3 + + + 7 + + + + + 8 + + + + + 9 + + + + + + diff --git a/python/tests/data/svg/ts_no_axes.svg b/python/tests/data/svg/ts_no_axes.svg new file mode 100644 index 0000000000..b2d87ef320 --- /dev/null +++ b/python/tests/data/svg/ts_no_axes.svg @@ -0,0 +1,235 @@ + + + + + + + + + + + + + + + 0 + + + + + 1 + + + + 4 + + + + + + 2 + + + + + 3 + + + + + + 2 + + + 5 + + + + + + 0 + + + + + 1 + + + 9 + + + + + + + + + + + 0 + + + + + 1 + + + + + + 3 + + + + + 4 + + + 4 + + + + + + 2 + + + + + 3 + + + + 5 + + + + + + 5 + + + 7 + + + + + + + + + + + 0 + + + + + 1 + + + + 4 + + + + + + 2 + + + + + 3 + + + + 5 + + + + 6 + + + + + + + + + + + 0 + + + + + 1 + + + + 4 + + + + + + 2 + + + + + 3 + + + + 5 + + + + 7 + + + + + + + + + + + 0 + + + + + 1 + + + + 4 + + + + + + 2 + + + + + 3 + + + + 5 + + + + 8 + + + + + + diff --git a/python/tests/data/svg/ts_nonbinary.svg b/python/tests/data/svg/ts_nonbinary.svg index 1a409c1a6b..1e291aa863 100644 --- a/python/tests/data/svg/ts_nonbinary.svg +++ b/python/tests/data/svg/ts_nonbinary.svg @@ -1,4088 +1,4175 @@ - + - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - + + + + Genome position + + + + + + 0.00 + + + + + + 0.06 + + + + + + 0.13 + + + + + + 0.14 + + + + + + 0.14 + + + + + + 0.17 + + + + + + 0.17 + + + + + + 0.19 + + + + + + 0.20 + + + + + + 0.20 + + + + + + 0.23 + + + + + + 0.26 + + + + + + 0.32 + + + + + + 0.38 + + + + + + 0.39 + + + + + + 0.47 + + + + + + 0.55 + + + + + + 0.55 + + + + + + 0.57 + + + + + + 0.57 + + + + + + 0.57 + + + + + + 0.59 + + + + + + 0.60 + + + + + + 0.61 + + + + + + 0.62 + + + + + + 0.69 + + + + + + 0.70 + + + + + + 0.71 + + + + + + 0.77 + + + + + + 0.86 + + + + + + 0.90 + + + + + + 0.98 + + + + + + 1.00 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - 7 + 7 - - - + + + - 0 + 0 - - + + - 1 + 1 - - + + - 3 + 3 - - + + - 4 + 4 - + - 13 + 13 - - - + + + 0 - - + + 1 - 15 + 15 - - - + + + - 2 + 2 - - - + + + - 5 + 5 - - - + + + - 8 + 8 - - - + + + - 6 + 6 - - + + - 9 + 9 - + - 10 + 10 - + - 11 + 11 - + - 12 + 12 - + - 17 + 17 - 30 + 30 - - - - - - + + + + + + - 7 + 7 - - - + + + - 0 + 0 - - + + - 1 + 1 - - + + - 3 + 3 - - + + - 4 + 4 - - - + + + 10 - 13 + 13 - - - + + + 2 - - + + 3 - - + + 5 - - + + 8 - - + + 11 - 15 + 15 - - - + + + - 2 + 2 - - - + + + - 5 + 5 - - - + + + - 8 + 8 - - - + + + - 6 + 6 - - + + - 9 + 9 - + - 10 + 10 - + - 11 + 11 - - - + + + 7 - 12 + 12 - - - + + + 4 - - + + 6 - - + + 9 - 17 + 17 - 34 + 34 - - - - - - + + + + + + - 5 + 5 - - - + + + - 8 + 8 - - - + + + - 6 + 6 - - + + - 9 + 9 - + - 10 + 10 - + - 11 + 11 - + - 12 + 12 - - - + + + - 2 + 2 - - - + + + - 7 + 7 - - - + + + - 0 + 0 - - + + - 1 + 1 - - + + - 3 + 3 - - + + - 4 + 4 - + - 13 + 13 - + - 15 + 15 - + - 29 + 29 - 34 + 34 - - - - - - + + + + + + - 7 + 7 - - - + + + - 0 + 0 - - + + - 1 + 1 - - + + - 3 + 3 - - + + - 4 + 4 - + - 13 + 13 - - - + + + 12 - - + + 14 - 15 + 15 - - - - - + + + + + 13 - 2 + 2 - - - + + + - 5 + 5 - - - + + + - 8 + 8 - - - + + + - 6 + 6 - - + + - 9 + 9 - + - 10 + 10 - + - 11 + 11 - + - 12 + 12 - + - 25 + 25 - 34 + 34 - - - - - - + + + + + + - 5 + 5 - - - + + + - 8 + 8 - - - + + + - 6 + 6 - - + + - 9 + 9 - + - 10 + 10 - + - 11 + 11 - + - 12 + 12 - - - - - + + + + + 15 - - + + 16 - - + + 17 - 2 + 2 - - - + + + - 7 + 7 - - - + + + - 0 + 0 - - + + - 1 + 1 - - + + - 3 + 3 - - + + - 4 + 4 - + - 13 + 13 - + - 15 + 15 - + - 24 + 24 - 25 + 25 - - - - - - + + + + + + - 7 + 7 - - - + + + - 0 + 0 - - + + - 1 + 1 - - + + - 3 + 3 - - + + - 4 + 4 - + - 13 + 13 - + - 15 + 15 - - - - + + + + - 5 + 5 - - - + + + - 8 + 8 - - - + + + - 6 + 6 - - + + - 9 + 9 - + - 10 + 10 - + - 11 + 11 - + - 12 + 12 - - - - + + + + 18 - 2 + 2 - + - 25 + 25 - 34 + 34 - - - - - - + + + + + + - 7 + 7 - - - + + + - 0 + 0 - - + + - 1 + 1 - - + + - 3 + 3 - - + + - 4 + 4 - + - 13 + 13 - - - + + + 21 - 15 + 15 - - - - - + + + + + 19 - - + + 20 - 2 + 2 - - - + + + - 5 + 5 - - - + + + - 8 + 8 - - - + + + - 6 + 6 - - + + - 9 + 9 - + - 10 + 10 - + - 11 + 11 - + - 12 + 12 - + - 18 + 18 - 34 + 34 - - - - - - + + + + + + - 7 + 7 - - - + + + - 0 + 0 - - + + - 1 + 1 - - + + - 3 + 3 - - + + - 4 + 4 - + - 13 + 13 - + - 15 + 15 - - - - - + + + + + 24 - 2 + 2 - - - + + + - 5 + 5 - - - + + + - 8 + 8 - - - + + + - 6 + 6 - - + + - 9 + 9 - + - 10 + 10 - + - 11 + 11 - - - + + + 22 - 12 + 12 - - - + + + 23 - 18 + 18 - 35 + 35 - - - - - - + + + + + + - 0 + 0 - - + + - 1 + 1 - - + + - 3 + 3 - - + + - 4 + 4 - + - 13 + 13 - - - + + + - 7 + 7 - - - + + + - 2 + 2 - - - + + + - 5 + 5 - - - + + + - 8 + 8 - - - + + + - 6 + 6 - - + + - 9 + 9 - + - 10 + 10 - + - 11 + 11 - + - 12 + 12 - + - 18 + 18 - + - 20 + 20 - 35 + 35 - - - - - - + + + + + + - 0 + 0 - - + + - 1 + 1 - - + + - 3 + 3 - - + + - 4 + 4 - - - - + + + + 26 - 2 + 2 - - - + + + 25 - - + + 27 - - + + 29 - 13 + 13 - - - - - + + + + + 28 - 7 + 7 - - - + + + - 5 + 5 - - - + + + - 8 + 8 - - - + + + - 6 + 6 - - + + - 9 + 9 - + - 10 + 10 - + - 11 + 11 - + - 12 + 12 - - - + + + 30 - 20 + 20 - 35 + 35 - - - - - - + + + + + + - 0 + 0 - - + + - 1 + 1 - - + + - 3 + 3 - - + + - 4 + 4 - - - - + + + + 33 - 2 + 2 - - - + + + 31 - - + + 35 - 13 + 13 - - - + + + - 7 + 7 - - - + + + - 5 + 5 - - - - - + + + + + 32 - 8 + 8 - - - + + + - 6 + 6 - - + + - 9 + 9 - + - 10 + 10 - + - 11 + 11 - - - + + + 34 - 12 + 12 - + - 16 + 16 - 35 + 35 - - - - - - + + + + + + - 0 + 0 - - + + - 1 + 1 - - + + - 3 + 3 - - + + - 4 + 4 - - + + - 2 + 2 - - - + + + 36 - - + + 37 - - + + 39 - 13 + 13 - - - + + + - 7 + 7 - - - + + + - 5 + 5 - - - + + + - 8 + 8 - - - + + + - 6 + 6 - - + + - 9 + 9 - + - 10 + 10 - + - 11 + 11 - + - 12 + 12 - - - + + + 38 - 16 + 16 - 33 + 33 - - - - - - + + + + + + - 0 + 0 - - + + - 1 + 1 - - + + - 3 + 3 - - + + - 4 + 4 - - + + - 2 + 2 - - - + + + 42 - - + + 45 - 13 + 13 - - - - - + + + + + 46 - 7 + 7 - - - + + + - 5 + 5 - - - + + + - 8 + 8 - - - + + + - 6 + 6 - - + + - 9 + 9 - + - 10 + 10 - + - 11 + 11 - - - + + + 41 - - + + 44 - 12 + 12 - - - + + + 40 - - + + 43 - 16 + 16 - 28 + 28 - - - - - - + + + + + + - 0 + 0 - - + + - 1 + 1 - - + + - 3 + 3 - - + + - 4 + 4 - - + + - 2 + 2 - + - 13 + 13 - - - + + + - 7 + 7 - - - + + + - 5 + 5 - - - + + + - 8 + 8 - - - + + + - 6 + 6 - - + + - 9 + 9 - + - 10 + 10 - + - 11 + 11 - + - 12 + 12 - + - 16 + 16 - 33 + 33 - - - - - - + + + + + + - 0 + 0 - - + + - 1 + 1 - - + + - 3 + 3 - - + + - 4 + 4 - - + + - 2 + 2 - - - + + + 47 - - + + 48 - - + + 50 - - + + 51 - - + + 53 - 13 + 13 - - - + + + - 7 + 7 - - - + + + - 5 + 5 - - - + + + - 8 + 8 - - - + + + - 6 + 6 - - + + - 9 + 9 - + - 10 + 10 - + - 11 + 11 - + - 12 + 12 - - - + + + 49 - - + + 52 - 16 + 16 - 35 + 35 - - - - - - + + + + + + - 0 + 0 - - + + - 1 + 1 - - + + - 3 + 3 - - + + - 4 + 4 - - + + - 2 + 2 - - - + + + 55 - 13 + 13 - - - + + + - 7 + 7 - - - + + + - 5 + 5 - - - + + + - 8 + 8 - - - + + + - 6 + 6 - - + + - 9 + 9 - + - 10 + 10 - + - 11 + 11 - + - 12 + 12 - - - + + + 54 - 16 + 16 - 19 + 19 - - - - - + + + + + - 0 + 0 - - - - + + + + - 1 + 1 - - + + - 3 + 3 - - + + - 4 + 4 - - + + - 2 + 2 - + - 13 + 13 - - - - - + + + + + 56 - 7 + 7 - - - + + + - 5 + 5 - - - + + + - 8 + 8 - - - + + + - 6 + 6 - - + + - 9 + 9 - + - 10 + 10 - + - 11 + 11 - + - 12 + 12 - + - 16 + 16 - + - 19 + 19 - 38 + 38 - - - - - - - + + + + + + + 57 - 0 + 0 - - - - + + + + - 1 + 1 - - + + - 3 + 3 - - + + - 4 + 4 - - + + - 2 + 2 - + - 13 + 13 - - - + + + - 7 + 7 - - - + + + - 5 + 5 - - - + + + - 8 + 8 - - - + + + - 6 + 6 - - + + - 9 + 9 - + - 10 + 10 - + - 11 + 11 - - - + + + 58 - 12 + 12 - + - 16 + 16 - - - + + + 59 - 19 + 19 - 36 + 36 - - - - - - - + + + + + + + 60 - 0 + 0 - - - - + + + + - 1 + 1 - - + + - 3 + 3 - - + + - 4 + 4 - - + + - 2 + 2 - + - 13 + 13 - - - + + + - 7 + 7 - - - + + + - 5 + 5 - - - + + + - 8 + 8 - - - + + + - 6 + 6 - - + + - 9 + 9 - + - 10 + 10 - + - 11 + 11 - + - 12 + 12 - + - 16 + 16 - + - 19 + 19 - 37 + 37 - - - - - + + + + + - 0 + 0 - - - - + + + + - 1 + 1 - - + + - 3 + 3 - - + + - 4 + 4 - - + + - 2 + 2 - + - 13 + 13 - - - + + + - 7 + 7 - - - + + + - 5 + 5 - - - + + + - 8 + 8 - - - + + + - 6 + 6 - - + + - 9 + 9 - + - 10 + 10 - + - 11 + 11 - + - 12 + 12 - + - 16 + 16 - + - 19 + 19 - 40 + 40 - - - - - + + + + + - 0 + 0 - - - - + + + + - 1 + 1 - - + + - 3 + 3 - - + + - 4 + 4 - - + + - 2 + 2 - + - 13 + 13 - - - - - + + + + + 61 - 7 + 7 - - - + + + - 5 + 5 - - - + + + - 8 + 8 - - - + + + - 6 + 6 - - + + - 9 + 9 - + - 10 + 10 - + - 11 + 11 - + - 12 + 12 - - - + + + 63 - 16 + 16 - - - + + + 62 - 31 + 31 - 40 + 40 - - - - - - + + + + + + - 1 + 1 - - + + - 3 + 3 - - + + - 4 + 4 - - + + - 2 + 2 - - - + + + 64 - - + + 65 - - + + 66 - - + + 67 - 13 + 13 - - - + + + - 0 + 0 - - - + + + - 7 + 7 - - - + + + - 5 + 5 - - - + + + - 8 + 8 - - - + + + - 6 + 6 - - + + - 9 + 9 - + - 10 + 10 - + - 11 + 11 - + - 12 + 12 - - - + + + 68 - 16 + 16 - + - 32 + 32 - 40 + 40 - - - - - - + + + + + + - 7 + 7 - - - + + + - 5 + 5 - - - + + + - 8 + 8 - - - + + + - 6 + 6 - - + + - 9 + 9 - + - 10 + 10 - + - 11 + 11 - + - 12 + 12 - - - + + + 72 - 16 + 16 - - - + + + - 0 + 0 - - - + + + - 1 + 1 - - + + - 3 + 3 - - + + - 4 + 4 - - + + - 2 + 2 - + - 13 + 13 - - - + + + 69 - - + + 70 - - + + 71 - 23 + 23 - 40 + 40 - - - - - - + + + + + + - 7 + 7 - - - + + + - 5 + 5 - - - + + + - 8 + 8 - - - + + + - 6 + 6 - - + + - 9 + 9 - + - 10 + 10 - + - 11 + 11 - + - 12 + 12 - - - + + + 73 - 16 + 16 - - - + + + - 0 + 0 - - - + + + - 1 + 1 - - + + - 3 + 3 - - + + - 4 + 4 - - + + - 2 + 2 - + - 13 + 13 - + - 23 + 23 - 39 + 39 - - - - - - + + + + + + - 1 + 1 - - + + - 3 + 3 - - + + - 4 + 4 - - + + - 2 + 2 - - - + + + 78 - - + + 79 - - + + 84 - - + + 85 - - + + 86 - 13 + 13 - - - - - + + + + + 76 - 0 + 0 - - - - - + + + + + 74 - - + + 75 - 7 + 7 - - - + + + - 5 + 5 - - - + + + - 8 + 8 - - - + + + - 6 + 6 - - + + - 9 + 9 - + - 10 + 10 - + - 11 + 11 - - - + + + 77 - - + + 82 - 12 + 12 - + - 16 + 16 - - - + + + 80 - - + + 81 - - + + 83 - 22 + 22 - 39 + 39 - - - - - - + + + + + + - 1 + 1 - - + + - 3 + 3 - - + + - 4 + 4 - - + + - 2 + 2 - - - + + + 87 - 13 + 13 - - - - + + + + - 5 + 5 - - - + + + - 8 + 8 - - - + + + - 6 + 6 - - + + - 9 + 9 - + - 10 + 10 - + - 11 + 11 - + - 12 + 12 - - - + + + - 0 + 0 - - + + - 7 + 7 - + - 14 + 14 - + - 16 + 16 - 39 + 39 - - - - - - + + + + + + - 1 + 1 - - + + - 3 + 3 - - + + - 4 + 4 - - + + - 2 + 2 - + - 13 + 13 - - - - + + + + - 5 + 5 - - - + + + - 8 + 8 - - - + + + - 6 + 6 - - + + - 9 + 9 - + - 10 + 10 - + - 11 + 11 - + - 12 + 12 - - - + + + - 0 + 0 - - + + - 7 + 7 - + - 14 + 14 - + - 16 + 16 - 27 + 27 - - - - - - + + + + + + - 1 + 1 - - + + - 3 + 3 - - + + - 4 + 4 - - + + - 2 + 2 - - - + + + 89 - - + + 90 - 13 + 13 - - - - + + + + - 5 + 5 - - - + + + - 8 + 8 - - - + + + - 6 + 6 - - + + - 9 + 9 - + - 10 + 10 - + - 11 + 11 - + - 12 + 12 - - - + + + - 0 + 0 - - - - + + + + 88 - - + + 91 - 7 + 7 - + - 14 + 14 - + - 16 + 16 - 26 + 26 - - - - - - + + + + + + - 1 + 1 - - + + - 3 + 3 - - + + - 4 + 4 - - + + - 2 + 2 - - - + + + 94 - - + + 95 - - + + 96 - 13 + 13 - - - - + + + + - 5 + 5 - - - + + + - 8 + 8 - - - + + + - 6 + 6 - - + + - 9 + 9 - + - 10 + 10 - + - 11 + 11 - + - 12 + 12 - - - + + + - 0 + 0 - - - - + + + + 92 - 7 + 7 - - - + + + 93 - 14 + 14 - + - 16 + 16 - 27 + 27 - - - - - - + + + + + + - 1 + 1 - - + + - 3 + 3 - - + + - 4 + 4 - - + + - 2 + 2 - - - + + + 100 - 13 + 13 - - - - + + + + - 5 + 5 - - - + + + - 8 + 8 - - - + + + - 6 + 6 - - + + - 9 + 9 - + - 10 + 10 - + - 11 + 11 - + - 12 + 12 - - - + + + - 0 + 0 - - + + - 7 + 7 - + - 14 + 14 - - - + + + 97 - - + + 98 - - + + 99 - 16 + 16 - 32 + 32 - - - - - - + + + + + + - 1 + 1 - - + + - 3 + 3 - - - - + + + + 102 - 4 + 4 - - + + - 2 + 2 - - - + + + 101 - 13 + 13 - - - - + + + + - 5 + 5 - - - + + + - 8 + 8 - - - + + + - 6 + 6 - - + + - 9 + 9 - + - 10 + 10 - + - 11 + 11 - + - 12 + 12 - - - + + + - 0 + 0 - - + + - 7 + 7 - + - 14 + 14 - + - 16 + 16 - 21 + 21 - - - - - - + + + + + + - 1 + 1 - - + + - 3 + 3 - - + + - 4 + 4 - - + + - 2 + 2 - - - + + + 103 - 13 + 13 - - - - + + + + - 5 + 5 - - - + + + - 8 + 8 - - - + + + - 6 + 6 - - + + - 9 + 9 - + - 10 + 10 - + - 11 + 11 - + - 12 + 12 - - - + + + - 0 + 0 - - + + - 7 + 7 - + - 14 + 14 - + - 16 + 16 - 32 + 32 - - - - - 0.00 - - - - 0.06 - - - - 0.13 - - - - 0.14 - - - - 0.14 - - - - 0.17 - - - - 0.17 - - - - 0.19 - - - - 0.20 - - - - 0.20 - - - - 0.23 - - - - 0.26 - - - - 0.32 - - - - 0.38 - - - - 0.39 - - - - 0.47 - - - - 0.55 - - - - 0.55 - - - - 0.57 - - - - 0.57 - - - - 0.57 - - - - 0.59 - - - - 0.60 - - - - 0.61 - - - - 0.62 - - - - 0.69 - - - - 0.70 - - - - 0.71 - - - - 0.77 - - - - 0.86 - - - - 0.90 - - - - 0.98 - - - - 1.00 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/python/tests/data/svg/ts_plain.svg b/python/tests/data/svg/ts_plain.svg index b17eea874f..1d83e8b1fc 100644 --- a/python/tests/data/svg/ts_plain.svg +++ b/python/tests/data/svg/ts_plain.svg @@ -1,262 +1,279 @@ - + - - - - - - - + + + + Genome position + + + + + + 0.00 + + + + + + 0.06 + + + + + + 0.79 + + + + + + 0.91 + + + + + + 0.91 + + + + + + 1.00 + + + + + + + + + + + - 0 + 0 - - + + - 1 + 1 - + - 4 + 4 - - - + + + - 2 + 2 - - + + - 3 + 3 - - - + + + 2 - 5 + 5 - - - + + + 0 - - + + 1 - 9 + 9 - - - - - - + + + + + + - 0 + 0 - - + + - 1 + 1 - - - + + + 3 - - + + 4 - 4 + 4 - - - + + + - 2 + 2 - - + + - 3 + 3 - + - 5 + 5 - - - + + + 5 - 7 + 7 - - - - - - + + + + + + - 0 + 0 - - + + - 1 + 1 - + - 4 + 4 - - - + + + - 2 + 2 - - + + - 3 + 3 - + - 5 + 5 - + - 6 + 6 - - - - - - + + + + + + - 0 + 0 - - + + - 1 + 1 - + - 4 + 4 - - - + + + - 2 + 2 - - + + - 3 + 3 - + - 5 + 5 - + - 7 + 7 - - - - - - + + + + + + - 0 + 0 - - + + - 1 + 1 - + - 4 + 4 - - - + + + - 2 + 2 - - + + - 3 + 3 - + - 5 + 5 - + - 8 + 8 - - - - - 0.00 - - - - 0.06 - - - - 0.79 - - - - 0.91 - - - - 0.91 - - - - 1.00 - - diff --git a/python/tests/data/svg/ts_plain_no_xlab.svg b/python/tests/data/svg/ts_plain_no_xlab.svg new file mode 100644 index 0000000000..5923917ec2 --- /dev/null +++ b/python/tests/data/svg/ts_plain_no_xlab.svg @@ -0,0 +1,276 @@ + + + + + + + + + + + + + 0.00 + + + + + + 0.06 + + + + + + 0.79 + + + + + + 0.91 + + + + + + 0.91 + + + + + + 1.00 + + + + + + + + + + + + + 0 + + + + + 1 + + + + 4 + + + + + + 2 + + + + + 3 + + + + + + 2 + + + 5 + + + + + + 0 + + + + + 1 + + + 9 + + + + + + + + + + + 0 + + + + + 1 + + + + + + 3 + + + + + 4 + + + 4 + + + + + + 2 + + + + + 3 + + + + 5 + + + + + + 5 + + + 7 + + + + + + + + + + + 0 + + + + + 1 + + + + 4 + + + + + + 2 + + + + + 3 + + + + 5 + + + + 6 + + + + + + + + + + + 0 + + + + + 1 + + + + 4 + + + + + + 2 + + + + + 3 + + + + 5 + + + + 7 + + + + + + + + + + + 0 + + + + + 1 + + + + 4 + + + + + + 2 + + + + + 3 + + + + 5 + + + + 8 + + + + + + diff --git a/python/tests/data/svg/ts_plain_y.svg b/python/tests/data/svg/ts_plain_y.svg new file mode 100644 index 0000000000..4e62bf0b7f --- /dev/null +++ b/python/tests/data/svg/ts_plain_y.svg @@ -0,0 +1,306 @@ + + + + + + + + + + Genome position + + + + + + 0.00 + + + + + + 0.06 + + + + + + 0.79 + + + + + + 0.91 + + + + + + 0.91 + + + + + + 1.00 + + + + + + Time + + + + + + + 0.00 + + + + + + + 5.00 + + + + + + + 10.00 + + + + + + + + + + + + + 0 + + + + + 1 + + + + 4 + + + + + + 2 + + + + + 3 + + + + + + 2 + + + 5 + + + + + + 0 + + + + + 1 + + + 9 + + + + + + + + + + + 0 + + + + + 1 + + + + + + 3 + + + + + 4 + + + 4 + + + + + + 2 + + + + + 3 + + + + 5 + + + + + + 5 + + + 7 + + + + + + + + + + + 0 + + + + + 1 + + + + 4 + + + + + + 2 + + + + + 3 + + + + 5 + + + + 6 + + + + + + + + + + + 0 + + + + + 1 + + + + 4 + + + + + + 2 + + + + + 3 + + + + 5 + + + + 7 + + + + + + + + + + + 0 + + + + + 1 + + + + 4 + + + + + + 2 + + + + + 3 + + + + 5 + + + + 8 + + + + + + diff --git a/python/tests/data/svg/ts_rank.svg b/python/tests/data/svg/ts_rank.svg index bdeaebde0f..ffaad8d8b0 100644 --- a/python/tests/data/svg/ts_rank.svg +++ b/python/tests/data/svg/ts_rank.svg @@ -1,297 +1,364 @@ - + - - - + + + + + - - - - - - - + + + + Genome position + + + + + + 0.00 + + + + + + 0.06 + + + + + + 0.79 + + + + + + 0.91 + + + + + + 0.91 + + + + + + 1.00 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Ranked node time + + + + + + 0.00 + + + + + + 1.00 + + + + + + 2.00 + + + + + + 3.00 + + + + + + 4.00 + + + + + + 5.00 + + + + + + 6.00 + + + + + + + + + + + - 0 + 0 - - + + - 1 + 1 - + - 4 + 4 - - - + + + - 2 + 2 - - + + - 3 + 3 - - - + + + 2 - 5 + 5 - - - + + + 0 - - + + 1 - 9 + 9 - - - - - - + + + + + + - 0 + 0 - - + + - 1 + 1 - - - + + + 3 - - + + 4 - 4 + 4 - - - + + + - 2 + 2 - - + + - 3 + 3 - + - 5 + 5 - - - + + + 5 - 7 + 7 - - - - - - + + + + + + - 0 + 0 - - + + - 1 + 1 - + - 4 + 4 - - - + + + - 2 + 2 - - + + - 3 + 3 - + - 5 + 5 - + - 6 + 6 - - - - - - + + + + + + - 0 + 0 - - + + - 1 + 1 - + - 4 + 4 - - - + + + - 2 + 2 - - + + - 3 + 3 - + - 5 + 5 - + - 7 + 7 - - - - - - + + + + + + - 0 + 0 - - + + - 1 + 1 - + - 4 + 4 - - - + + + - 2 + 2 - - + + - 3 + 3 - + - 5 + 5 - + - 8 + 8 - - - - - 0.00 - - - - 0.06 - - - - 0.79 - - - - 0.91 - - - - 0.91 - - - - 1.00 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/python/tests/data/svg/ts_xlabel.svg b/python/tests/data/svg/ts_xlabel.svg index 6306b4a827..15c54c80a1 100644 --- a/python/tests/data/svg/ts_xlabel.svg +++ b/python/tests/data/svg/ts_xlabel.svg @@ -1,300 +1,316 @@ - + - - - + + + + + - - - - - - - + + + + genomic position (bp) + + + + + + 0.00 + + + + + + 0.06 + + + + + + 0.79 + + + + + + 0.91 + + + + + + 0.91 + + + + + + 1.00 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - 0 + 0 - - + + - 1 + 1 - + - 4 + 4 - - - + + + - 2 + 2 - - + + - 3 + 3 - - - + + + 2 - 5 + 5 - - - + + + 0 - - + + 1 - 9 + 9 - - - - - - + + + + + + - 0 + 0 - - + + - 1 + 1 - - - + + + 3 - - + + 4 - 4 + 4 - - - + + + - 2 + 2 - - + + - 3 + 3 - + - 5 + 5 - - - + + + 5 - 7 + 7 - - - - - - + + + + + + - 0 + 0 - - + + - 1 + 1 - + - 4 + 4 - - - + + + - 2 + 2 - - + + - 3 + 3 - + - 5 + 5 - + - 6 + 6 - - - - - - + + + + + + - 0 + 0 - - + + - 1 + 1 - + - 4 + 4 - - - + + + - 2 + 2 - - + + - 3 + 3 - + - 5 + 5 - + - 7 + 7 - - - - - - + + + + + + - 0 + 0 - - + + - 1 + 1 - + - 4 + 4 - - - + + + - 2 + 2 - - + + - 3 + 3 - + - 5 + 5 - + - 8 + 8 - - - - - 0.00 - - - - 0.06 - - - - 0.79 - - - - 0.91 - - - - 0.91 - - - - 1.00 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - genomic position (bp) - - diff --git a/python/tests/data/svg/ts_y_axis.svg b/python/tests/data/svg/ts_y_axis.svg new file mode 100644 index 0000000000..308945ba8d --- /dev/null +++ b/python/tests/data/svg/ts_y_axis.svg @@ -0,0 +1,364 @@ + + + + + + + + + + + + + + + + + Genome position + + + + + + 0.00 + + + + + + 0.06 + + + + + + 0.79 + + + + + + 0.91 + + + + + + 0.91 + + + + + + 1.00 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Time (gens) + + + + + + 0.00 + + + + + + 0.11 + + + + + + 1.11 + + + + + + 1.75 + + + + + + 5.31 + + + + + + 6.57 + + + + + + 9.08 + + + + + + + + + + + + + 0 + + + + + 1 + + + + 4 + + + + + + 2 + + + + + 3 + + + + + + 2 + + + 5 + + + + + + 0 + + + + + 1 + + + 9 + + + + + + + + + + + 0 + + + + + 1 + + + + + + 3 + + + + + 4 + + + 4 + + + + + + 2 + + + + + 3 + + + + 5 + + + + + + 5 + + + 7 + + + + + + + + + + + 0 + + + + + 1 + + + + 4 + + + + + + 2 + + + + + 3 + + + + 5 + + + + 6 + + + + + + + + + + + 0 + + + + + 1 + + + + 4 + + + + + + 2 + + + + + 3 + + + + 5 + + + + 7 + + + + + + + + + + + 0 + + + + + 1 + + + + 4 + + + + + + 2 + + + + + 3 + + + + 5 + + + + 8 + + + + + + diff --git a/python/tests/data/svg/ts_y_axis_log.svg b/python/tests/data/svg/ts_y_axis_log.svg new file mode 100644 index 0000000000..ab0732f08c --- /dev/null +++ b/python/tests/data/svg/ts_y_axis_log.svg @@ -0,0 +1,364 @@ + + + + + + + + + + + + + + + + + Genome position + + + + + + 0.00 + + + + + + 0.06 + + + + + + 0.79 + + + + + + 0.91 + + + + + + 0.91 + + + + + + 1.00 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Time (log scale) + + + + + + 0.00 + + + + + + 0.11 + + + + + + 1.11 + + + + + + 1.75 + + + + + + 5.31 + + + + + + 6.57 + + + + + + 9.08 + + + + + + + + + + + + + 0 + + + + + 1 + + + + 4 + + + + + + 2 + + + + + 3 + + + + + + 2 + + + 5 + + + + + + 0 + + + + + 1 + + + 9 + + + + + + + + + + + 0 + + + + + 1 + + + + + + 3 + + + + + 4 + + + 4 + + + + + + 2 + + + + + 3 + + + + 5 + + + + + + 5 + + + 7 + + + + + + + + + + + 0 + + + + + 1 + + + + 4 + + + + + + 2 + + + + + 3 + + + + 5 + + + + 6 + + + + + + + + + + + 0 + + + + + 1 + + + + 4 + + + + + + 2 + + + + + 3 + + + + 5 + + + + 7 + + + + + + + + + + + 0 + + + + + 1 + + + + 4 + + + + + + 2 + + + + + 3 + + + + 5 + + + + 8 + + + + + + diff --git a/python/tests/data/svg/ts_y_axis_regular.svg b/python/tests/data/svg/ts_y_axis_regular.svg new file mode 100644 index 0000000000..59130d2392 --- /dev/null +++ b/python/tests/data/svg/ts_y_axis_regular.svg @@ -0,0 +1,392 @@ + + + + + + + + + + + + + + + + + Genome position + + + + + + 0.00 + + + + + + 0.06 + + + + + + 0.79 + + + + + + 0.91 + + + + + + 0.91 + + + + + + 1.00 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Time + + + + + + + 0.00 + + + + + + + 1.00 + + + + + + + 2.00 + + + + + + + 3.00 + + + + + + + 4.00 + + + + + + + 5.00 + + + + + + + 6.00 + + + + + + + 7.00 + + + + + + + 8.00 + + + + + + + 9.00 + + + + + + + + + + + + + 0 + + + + + 1 + + + + 4 + + + + + + 2 + + + + + 3 + + + + + + 2 + + + 5 + + + + + + 0 + + + + + 1 + + + 9 + + + + + + + + + + + 0 + + + + + 1 + + + + + + 3 + + + + + 4 + + + 4 + + + + + + 2 + + + + + 3 + + + + 5 + + + + + + 5 + + + 7 + + + + + + + + + + + 0 + + + + + 1 + + + + 4 + + + + + + 2 + + + + + 3 + + + + 5 + + + + 6 + + + + + + + + + + + 0 + + + + + 1 + + + + 4 + + + + + + 2 + + + + + 3 + + + + 5 + + + + 7 + + + + + + + + + + + 0 + + + + + 1 + + + + 4 + + + + + + 2 + + + + + 3 + + + + 5 + + + + 8 + + + + + + diff --git a/python/tests/test_drawing.py b/python/tests/test_drawing.py index 398e9dfacb..4e7b9ef21b 100644 --- a/python/tests/test_drawing.py +++ b/python/tests/test_drawing.py @@ -1234,7 +1234,6 @@ def test_draw_multiroot_forky_tree(self): def test_simple_tree_sequence(self): ts = self.get_simple_ts() - print(ts.draw_text()) ts_drawing = ( "9.08┊ 9 ┊ ┊ ┊ ┊ ┊\n" " ┊ ┏━┻━┓ ┊ ┊ ┊ ┊ ┊\n" @@ -1430,6 +1429,7 @@ def verify_basic_svg(self, svg, width=200, height=200, num_trees=1): root_group = root.find(prefix + "g") assert "class" in root_group.attrib assert re.search(r"\b(tree|tree-sequence)\b", root_group.attrib["class"]) + first_plotbox = None if "tree-sequence" in root_group.attrib["class"]: trees = None for g in root_group.findall(prefix + "g"): @@ -1437,16 +1437,19 @@ def verify_basic_svg(self, svg, width=200, height=200, num_trees=1): trees = g break assert trees is not None # Must have found a trees group - first_treebox = trees.find(prefix + "g") - assert "class" in first_treebox.attrib - assert re.search(r"\btreebox\b", first_treebox.attrib["class"]) - first_tree = first_treebox.find(prefix + "g") + first_tree = trees.find(prefix + "g") assert "class" in first_tree.attrib assert re.search(r"\btree\b", first_tree.attrib["class"]) + for g in first_tree.findall(prefix + "g"): + if "class" in g.attrib and re.search(r"\bplotbox\b", g.attrib["class"]): + first_plotbox = g else: - first_tree = root_group + for g in root_group.findall(prefix + "g"): + if "class" in g.attrib and re.search(r"\bplotbox\b", g.attrib["class"]): + first_plotbox = g + assert first_plotbox is not None # Check that we have edges, symbols, and labels groups - groups = first_tree.findall(prefix + "g") + groups = first_plotbox.findall(prefix + "g") assert len(groups) > 0 for group in groups: assert "class" in group.attrib @@ -1481,6 +1484,28 @@ def test_draw_to_file(self, tmp_path): self.verify_basic_svg(svg, num_trees=ts.num_trees) self.verify_basic_svg(other_svg, num_trees=ts.num_trees) + def test_nonimplemented_base_class(self): + ts = self.get_simple_ts() + plot = drawing.SvgPlot(ts, (100, 100), {}, "", "dummy-class", None, True, True) + plot.set_spacing() + with pytest.raises(NotImplementedError): + plot.draw_x_axis(tick_positions=ts.breakpoints(as_array=True)) + with pytest.raises(NotImplementedError): + plot.draw_y_axis(tick_positions=[0]) + + def test_nonimplemented_tick_spacing(self): + t = self.get_binary_tree() + with pytest.raises(NotImplementedError): + t.draw_svg(y_axis=True, y_ticks=6) + ts = self.get_simple_ts() + with pytest.raises(NotImplementedError): + ts.draw_svg(y_axis=True, y_ticks=6) + + def test_no_mixed_yscales(self): + ts = self.get_simple_ts() + with pytest.raises(ValueError, match="varying yscales"): + ts.draw_svg(y_axis=True, max_tree_height="tree") + def test_draw_defaults(self): t = self.get_binary_tree() svg = t.draw() @@ -1603,7 +1628,8 @@ def test_unplotted_node(self): colour = None colours = {0: colour} svg = t.draw(format="svg", node_colours=colours) - assert svg.count("opacity:0") == 1 + svg_no_css = svg[svg.find("") :] + assert svg_no_css.count("opacity:0") == 1 def test_one_edge_colour(self): t = self.get_binary_tree() @@ -1681,7 +1707,18 @@ def test_unplotted_edge(self): colours = {0: colour} svg = t.draw(format="svg", edge_colours=colours) self.verify_basic_svg(svg) - assert svg.count("opacity:0") == 1 + svg_no_css = svg[svg.find("") :] + assert svg_no_css.count("opacity:0") == 1 + + def test_mutations_unknown_time(self): + ts = self.get_simple_ts(use_mutation_times=True) + svg = ts.draw_svg() + self.verify_basic_svg(svg, width=200 * ts.num_trees) + assert "unknown_time" not in svg + ts = self.get_simple_ts(use_mutation_times=False) + svg = ts.draw_svg() + self.verify_basic_svg(svg, width=200 * ts.num_trees) + assert svg.count("unknown_time") == ts.num_mutations def test_mutation_labels(self): t = self.get_binary_tree() @@ -1728,7 +1765,8 @@ def test_unplotted_mutation(self): colours = {0: colour} svg = t.draw(format="svg", mutation_colours=colours) self.verify_basic_svg(svg) - assert svg.count("fill-opacity:0") == 1 + svg_no_css = svg[svg.find("") :] + assert svg_no_css.count("fill-opacity:0") == 1 def test_max_tree_height(self): nodes = io.StringIO( @@ -1783,6 +1821,18 @@ def test_draw_sized_tree(self): svg = tree.draw_svg(size=(600, 400)) self.verify_basic_svg(svg, width=600, height=400) + def test_draw_bad_sized_treebox(self): + tree = self.get_binary_tree() + with pytest.raises(ValueError, match="too small to fit"): + # Too small for plotbox + tree.draw_svg(size=(20, 20)) + + def test_draw_bad_sized_tree(self): + tree = self.get_binary_tree() + with pytest.raises(ValueError, match="too small to allow space"): + # Too small for standard-sized labels on tree + tree.draw_svg(size=(50, 50)) + def test_draw_simple_ts(self): ts = msprime.simulate(5, recombination_rate=1, random_seed=1) svg = ts.draw_svg() @@ -1798,7 +1848,7 @@ def test_draw_integer_breaks_ts(self): assert ts.num_trees > 2 svg = ts.draw_svg() self.verify_basic_svg(svg, width=200 * ts.num_trees) - axis_pos = svg.find('class="axis"') + axis_pos = svg.find('class="x-axis"') for b in ts.breakpoints(): assert b == round(b) assert svg.find(f">{b:.0f}<", axis_pos) != -1 @@ -1838,15 +1888,65 @@ def test_bad_x_scale(self): with pytest.raises(ValueError): ts.draw_svg(x_scale=bad_x_scale) + def test_x_axis(self): + tree = msprime.simulate(4, random_seed=2).first() + svg = tree.draw_svg(x_axis=True) + svg_no_css = svg[svg.find("") :] + assert "Genome position" in svg_no_css + assert svg_no_css.count("axes") == 1 + assert svg_no_css.count("x-axis") == 1 + assert svg_no_css.count("y-axis") == 0 + + def test_y_axis(self): + tree = msprime.simulate(4, random_seed=2).first() + for hscale, label in [ + (None, "Time"), + ("time", "Time"), + ("log_time", "Time"), + ("rank", "Ranked node time"), + ]: + svg = tree.draw_svg(y_axis=True, tree_height_scale=hscale) + svg_no_css = svg[svg.find("") :] + assert label in svg_no_css + assert svg_no_css.count("axes") == 1 + assert svg_no_css.count("x-axis") == 0 + assert svg_no_css.count("y-axis") == 1 + assert svg_no_css.count("tick") == len({tree.time(u) for u in tree.nodes()}) + + def test_y_axis_noticks(self): + tree = msprime.simulate(4, random_seed=2).first() + svg = tree.draw_svg(y_label="Time", y_ticks=[]) + svg_no_css = svg[svg.find("") :] + assert svg_no_css.count("axes") == 1 + assert svg_no_css.count("x-axis") == 0 + assert svg_no_css.count("y-axis") == 1 + assert svg_no_css.count("tick") == 0 + + def test_symbol_size(self): + tree = msprime.simulate(4, random_seed=2, mutation_rate=8).first() + sz = 24 + svg = tree.draw_svg(symbol_size=sz) + svg_no_css = svg[svg.find("") :] + num_mutations = len([_ for _ in tree.mutations()]) + num_nodes = len([_ for _ in tree.nodes()]) + # Squares have 'height="sz" width="sz"' + assert svg_no_css.count(f'"{sz}"') == tree.num_samples() * 2 + # Circles define a radius like 'r="sz/2"' + assert svg_no_css.count(f'r="{sz/2:g}"') == num_nodes - tree.num_samples() + # Mutations draw a line on the cross using 'l sz,sz' + assert svg_no_css.count(f"l {sz},{sz} ") == num_mutations + def test_no_edges(self): ts = msprime.simulate(10, random_seed=2) tables = ts.dump_tables() tables.edges.clear() ts_no_edges = tables.tree_sequence() - svg = ts_no_edges.draw_svg() # This should just be a row of 10 sample nodes - self.verify_basic_svg(svg) - assert svg.count("rect") == 10 # Sample nodes are rectangles - assert svg.count('path class="edge"') == 0 + for tree_height_scale in ("time", "log_time", "rank"): + # SVG should just be a row of 10 sample nodes + svg = ts_no_edges.draw_svg(tree_height_scale=tree_height_scale) + self.verify_basic_svg(svg) + assert svg.count("rect") == 10 # Sample nodes are rectangles + assert svg.count('path class="edge"') == 0 svg = ts_no_edges.draw_svg(force_root_branch=True) self.verify_basic_svg(svg) @@ -1862,10 +1962,9 @@ def test_no_edges(self): svg = ts_no_edges.draw_svg() self.verify_basic_svg(svg) assert svg.count("rect") == 10 - assert svg.count('path class="edge"') == 10 - assert svg.count('path class="sym"') == ( - ts_no_edges.num_mutations + ts_no_edges.num_sites - ) + assert svg.count('") :] + assert svg_no_css.count("axes") == 0 + assert svg_no_css.count("x-axis") == 0 + assert svg_no_css.count("y-axis") == 0 self.verify_known_svg(svg, "tree.svg", overwrite_viz) - def test_known_svg_tree_root_mut(self, overwrite_viz): - tree = self.get_simple_ts().at_index(0) # Tree 0 has a few mutations above root + def test_known_svg_tree_x_axis(self, overwrite_viz, draw_plotbox): + tree = self.get_simple_ts().at_index(1) svg = tree.draw_svg( - root_svg_attributes={"id": "XYZ"}, style=".edge {stroke: blue}" - ) + x_axis=True, + x_label="pos on genome", + size=(400, 200), + debug_box=draw_plotbox, + ) + svg_no_css = svg[svg.find("") :] + assert svg_no_css.count("axes") == 1 + assert svg_no_css.count("x-axis") == 1 + assert svg_no_css.count("y-axis") == 0 + self.verify_known_svg(svg, "tree_x_axis.svg", overwrite_viz, width=400) + + def test_known_svg_tree_y_axis(self, overwrite_viz, draw_plotbox): + tree = self.get_simple_ts().at_index(1) + label = "Time (relative steps)" + svg = tree.draw_svg( + y_axis=True, + y_label=label, + y_gridlines=True, + tree_height_scale="rank", + style=".y-axis line.grid {stroke: #CCCCCC}", + debug_box=draw_plotbox, + ) + svg_no_css = svg[svg.find("") :] + node_times = [tree.time(u) for u in tree.nodes()] + assert label in svg_no_css + assert svg_no_css.count('class="grid"') == len(set(node_times)) + assert svg_no_css.count("axes") == 1 + assert svg_no_css.count("x-axis") == 0 + assert svg_no_css.count("y-axis") == 1 + self.verify_known_svg(svg, "tree_y_axis.svg", overwrite_viz) + + def test_known_svg_tree_both_axes(self, overwrite_viz, draw_plotbox): + tree = self.get_simple_ts().at_index(-1) + svg = tree.draw_svg(x_axis=True, y_axis=True, debug_box=draw_plotbox) + svg_no_css = svg[svg.find("") :] + assert svg_no_css.count("axes") == 1 + assert svg_no_css.count("x-axis") == 1 + assert svg_no_css.count("y-axis") == 1 + self.verify_known_svg(svg, "tree_both_axes.svg", overwrite_viz) + + def test_known_svg_tree_root_mut(self, overwrite_viz, draw_plotbox): + tree = self.get_simple_ts().at_index(0) # Tree 0 has a few mutations above root + svg = tree.draw_svg(debug_box=draw_plotbox) self.verify_known_svg(svg, "tree_muts.svg", overwrite_viz) - def test_known_svg_tree_mut_no_edges(self, overwrite_viz): - ts = msprime.simulate(10, random_seed=2, mutation_rate=1) - tables = ts.dump_tables() - tables.edges.clear() - tree_no_edges = tables.tree_sequence().simplify().first() - svg = tree_no_edges.draw_svg() # A row of 10 sample nodes with root branches - self.verify_basic_svg(svg) - self.verify_known_svg( - svg, "tree_mutations_no_edges.svg", overwrite_viz, width=200 * ts.num_trees - ) + def test_known_svg_tree_timed_root_mut(self, overwrite_viz, draw_plotbox): + tree = self.get_simple_ts(use_mutation_times=True).at_index(0) + svg = tree.draw_svg(debug_box=draw_plotbox) + self.verify_known_svg(svg, "tree_timed_muts.svg", overwrite_viz) - def test_known_svg_ts(self, overwrite_viz): + def test_known_svg_ts(self, overwrite_viz, draw_plotbox): ts = self.get_simple_ts() - svg = ts.draw_svg() - assert svg.count('class="site ') == ts.num_sites - assert svg.count('class="mut ') == ts.num_mutations * 2 + svg = ts.draw_svg(debug_box=draw_plotbox) + svg_no_css = svg[svg.find("") :] + assert svg_no_css.count("axes") == 1 + assert svg_no_css.count("x-axis") == 1 + assert svg_no_css.count("y-axis") == 0 + assert svg_no_css.count('class="site ') == ts.num_sites + assert svg_no_css.count('class="mut ') == ts.num_mutations * 2 self.verify_known_svg(svg, "ts.svg", overwrite_viz, width=200 * ts.num_trees) - def test_known_svg_ts_internal_sample(self, overwrite_viz): + def test_known_svg_ts_no_axes(self, overwrite_viz, draw_plotbox): + ts = self.get_simple_ts() + svg = ts.draw_svg(x_axis=False, debug_box=draw_plotbox) + svg_no_css = svg[svg.find("") :] + assert svg_no_css.count("axes") == 0 + assert svg_no_css.count("x-axis") == 0 + assert svg_no_css.count("y-axis") == 0 + assert 'class="site ' not in svg_no_css + assert svg_no_css.count('class="mut ') == ts.num_mutations + self.verify_known_svg( + svg, "ts_no_axes.svg", overwrite_viz, width=200 * ts.num_trees + ) + + def test_known_svg_ts_internal_sample(self, overwrite_viz, draw_plotbox): ts = tsutil.jiggle_samples(self.get_simple_ts()) svg = ts.draw_svg( root_svg_attributes={"id": "XYZ"}, style="#XYZ .leaf .sym {fill: magenta} #XYZ .sample > .sym {fill: cyan}", + debug_box=draw_plotbox, ) self.verify_known_svg( svg, "internal_sample_ts.svg", overwrite_viz, width=200 * ts.num_trees ) - def test_known_svg_ts_highlighted_mut(self, overwrite_viz): + def test_known_svg_ts_highlighted_mut(self, overwrite_viz, draw_plotbox): ts = self.get_simple_ts() style = ( ".edge {stroke: grey}" @@ -1961,86 +2130,172 @@ def test_known_svg_ts_highlighted_mut(self, overwrite_viz): ".mut.m3 .sym,.m3>line, .m3>.node .edge{stroke:cyan} .mut.m3 text{fill:cyan}" ".mut.m4 .sym,.m4>line, .m4>.node .edge{stroke:blue} .mut.m4 text{fill:blue}" ) - svg = ts.draw_svg(style=style) + svg = ts.draw_svg(style=style, debug_box=draw_plotbox) self.verify_known_svg( svg, "ts_mut_highlight.svg", overwrite_viz, width=200 * ts.num_trees ) - def test_known_svg_ts_rank(self, overwrite_viz): + def test_known_svg_ts_rank(self, overwrite_viz, draw_plotbox): ts = self.get_simple_ts() - svg1 = ts.draw_svg(tree_height_scale="rank") + svg1 = ts.draw_svg( + tree_height_scale="rank", y_axis=True, debug_box=draw_plotbox + ) ts = self.get_simple_ts(use_mutation_times=True) - svg2 = ts.draw_svg(tree_height_scale="rank") - assert svg1 == svg2 # Must ignore mutation times if height is "rank" + svg2 = ts.draw_svg( + tree_height_scale="rank", y_axis=True, debug_box=draw_plotbox + ) assert svg1.count('class="site ') == ts.num_sites assert svg1.count('class="mut ') == ts.num_mutations * 2 + assert svg1.replace(" unknown_time", "") == svg2 # Trim the unknown_time class self.verify_known_svg( svg1, "ts_rank.svg", overwrite_viz, width=200 * ts.num_trees ) - def test_known_svg_nonbinary_ts(self, overwrite_viz): + def test_known_svg_nonbinary_ts(self, overwrite_viz, draw_plotbox): ts = self.get_nonbinary_ts() - svg = ts.draw_svg(tree_height_scale="log_time") + svg = ts.draw_svg(tree_height_scale="log_time", debug_box=draw_plotbox) assert svg.count('class="site ') == ts.num_sites assert svg.count('class="mut ') == ts.num_mutations * 2 self.verify_known_svg( svg, "ts_nonbinary.svg", overwrite_viz, width=200 * ts.num_trees ) - def test_known_svg_ts_plain(self, overwrite_viz): + def test_known_svg_ts_plain(self, overwrite_viz, draw_plotbox): """ Plain style: no background shading and a variable scale X axis with no sites """ ts = self.get_simple_ts() - svg = ts.draw_svg(x_scale="treewise") + svg = ts.draw_svg(x_scale="treewise", debug_box=draw_plotbox) assert svg.count('class="site ') == 0 assert svg.count('class="mut ') == ts.num_mutations self.verify_known_svg( svg, "ts_plain.svg", overwrite_viz, width=200 * ts.num_trees ) - def test_known_svg_ts_with_xlabel(self, overwrite_viz): + def test_known_svg_ts_plain_no_xlab(self, overwrite_viz, draw_plotbox): + """ + Plain style: no background shading and a variable scale X axis with no sites + """ + ts = self.get_simple_ts() + svg = ts.draw_svg(x_scale="treewise", x_label="", debug_box=draw_plotbox) + assert "Genome position" not in svg + self.verify_known_svg( + svg, "ts_plain_no_xlab.svg", overwrite_viz, width=200 * ts.num_trees + ) + + def test_known_svg_ts_plain_y(self, overwrite_viz, draw_plotbox): + """ + Plain style: no background shading and a variable scale X axis with no sites + """ + ts = self.get_simple_ts() + ticks = [0, 5, 10] + svg = ts.draw_svg( + x_scale="treewise", + y_axis=True, + y_ticks=ticks, + y_gridlines=True, + style=".y-axis line.grid {stroke: #CCCCCC}", + debug_box=draw_plotbox, + ) + self.verify_known_svg( + svg, "ts_plain_y.svg", overwrite_viz, width=200 * ts.num_trees + ) + + def test_known_svg_ts_with_xlabel(self, overwrite_viz, draw_plotbox): """ Style with X axis label """ ts = self.get_simple_ts() x_label = "genomic position (bp)" - svg = ts.draw_svg(x_label=x_label) + svg = ts.draw_svg(x_label=x_label, debug_box=draw_plotbox) assert x_label in svg self.verify_known_svg( svg, "ts_xlabel.svg", overwrite_viz, width=200 * ts.num_trees ) - def test_known_svg_ts_mutation_times(self, overwrite_viz): + def test_known_svg_ts_y_axis(self, overwrite_viz, draw_plotbox): + ts = self.get_simple_ts() + y_label = "Time (gens)" + svg = ts.draw_svg(y_axis=True, y_label=y_label, debug_box=draw_plotbox) + assert y_label in svg + self.verify_known_svg( + svg, "ts_y_axis.svg", overwrite_viz, width=200 * ts.num_trees + ) + + def test_known_svg_ts_y_axis_regular(self, overwrite_viz, draw_plotbox): + # This should have gridlines + ts = self.get_simple_ts() + ticks = np.arange(0, max(ts.tables.nodes.time), 1) + svg = ts.draw_svg( + y_axis=True, y_ticks=ticks, y_gridlines=True, debug_box=draw_plotbox + ) + assert svg.count('class="grid"') == len(ticks) + self.verify_known_svg( + svg, "ts_y_axis_regular.svg", overwrite_viz, width=200 * ts.num_trees + ) + + def test_known_svg_ts_y_axis_log(self, overwrite_viz, draw_plotbox): + ts = self.get_simple_ts() + svg = ts.draw_svg( + y_axis=True, + y_label="Time (log scale)", + tree_height_scale="log_time", + debug_box=draw_plotbox, + ) + self.verify_known_svg( + svg, "ts_y_axis_log.svg", overwrite_viz, width=200 * ts.num_trees + ) + + def test_known_svg_ts_mutation_times(self, overwrite_viz, draw_plotbox): ts = self.get_simple_ts(use_mutation_times=True) - svg = ts.draw_svg() + svg = ts.draw_svg(debug_box=draw_plotbox) assert svg.count('class="site ') == ts.num_sites assert svg.count('class="mut ') == ts.num_mutations * 2 self.verify_known_svg( svg, "ts_mut_times.svg", overwrite_viz, width=200 * ts.num_trees ) - def test_known_svg_ts_mutation_times_logscale(self, overwrite_viz): + def test_known_svg_ts_mutation_times_logscale(self, overwrite_viz, draw_plotbox): ts = self.get_simple_ts(use_mutation_times=True) - svg = ts.draw_svg(tree_height_scale="log_time") + svg = ts.draw_svg(tree_height_scale="log_time", debug_box=draw_plotbox) assert svg.count('class="site ') == ts.num_sites assert svg.count('class="mut ') == ts.num_mutations * 2 self.verify_known_svg( svg, "ts_mut_times_logscale.svg", overwrite_viz, width=200 * ts.num_trees ) - def test_known_svg_ts_mut_no_edges(self, overwrite_viz, caplog): + def test_known_svg_ts_mut_no_edges(self, overwrite_viz, draw_plotbox, caplog): + # An example with some muts on axis but not on a visible node ts = msprime.simulate(10, random_seed=2, mutation_rate=1) tables = ts.dump_tables() tables.edges.clear() + tables.mutations.time = np.full_like(tables.mutations.time, tskit.UNKNOWN_TIME) ts_no_edges = tables.tree_sequence() - svg = ts_no_edges.draw_svg() # Some muts on axis but not on a visible node + svg = ts_no_edges.draw_svg(debug_box=draw_plotbox) assert "not present in the displayed tree" in caplog.text self.verify_known_svg( svg, "ts_mutations_no_edges.svg", overwrite_viz, width=200 * ts.num_trees ) - def test_known_svg_ts_multiroot(self, overwrite_viz, caplog): + def test_known_svg_ts_timed_mut_no_edges(self, overwrite_viz, draw_plotbox, caplog): + # An example with some muts on axis but not on a visible node + ts = msprime.simulate(10, random_seed=2, mutation_rate=1) + tables = ts.dump_tables() + tables.edges.clear() + tables.mutations.time = np.arange( + ts.num_mutations, dtype=tables.mutations.time.dtype + ) + ts_no_edges = tables.tree_sequence() + svg = ts_no_edges.draw_svg(debug_box=draw_plotbox) + assert "not present in the displayed tree" in caplog.text + self.verify_known_svg( + svg, + "ts_mutations_timed_no_edges.svg", + overwrite_viz, + width=200 * ts.num_trees, + ) + + def test_known_svg_ts_multiroot(self, overwrite_viz, draw_plotbox, caplog): tables = wf.wf_sim( 6, 5, @@ -2052,7 +2307,9 @@ def test_known_svg_ts_multiroot(self, overwrite_viz, caplog): tables.sort() ts = tables.tree_sequence().simplify() ts = msprime.mutate(ts, rate=0.1, random_seed=123) - svg = ts.draw_svg() + svg = ts.draw_svg( + y_label="Time (WF gens)", y_gridlines=True, debug_box=draw_plotbox + ) self.verify_known_svg( svg, "ts_multiroot.svg", overwrite_viz, width=200 * ts.num_trees ) diff --git a/python/tskit/drawing.py b/python/tskit/drawing.py index b1299b88a7..0e3d3eb658 100644 --- a/python/tskit/drawing.py +++ b/python/tskit/drawing.py @@ -24,10 +24,12 @@ Module responsible for visualisations. """ import collections +import itertools import logging import math import numbers import operator +from dataclasses import dataclass import numpy as np import svgwrite @@ -120,6 +122,20 @@ def check_x_scale(x_scale): return x_scale +def check_ticks(ticks, default_iterable): + """ + If y_ticks is iterable, it is a list of tick positions. Otherwise return a default + or None if falsey + """ + if ticks is None: + return default_iterable + try: + iter(ticks) + return ticks + except TypeError: + raise NotImplementedError("Autocalculated tick mark locations not implemented.") + + def rnd(x): """ Round a number so that the output SVG doesn't have unneeded precision @@ -128,27 +144,12 @@ def rnd(x): if x == 0 or not math.isfinite(x): return x digits -= math.ceil(math.log10(abs(x))) - return round(x, digits) - - -def identity(x): + x = round(x, digits) + if int(x) == x: + return int(x) return x -def log_transform(x): - # add 1 so that don't reach log(0) = -inf error. - # just shifts entire timeset by 1 unit so shouldn't affect anything - return np.log(x + 1) - - -def add_text_in_group(dwg, elem, x, y, text, **kwargs): - """ - Add the text to the elem within a group. This allows text rotations to work smoothly - """ - grp = elem.add(dwg.g(transform=f"translate({rnd(x)}, {rnd(y)})")) - grp.add(dwg.text(text, **kwargs)) - - def draw_tree( tree, width=None, @@ -234,7 +235,355 @@ def remap_style(original_map, new_key, none_value): return str(text_tree) -class SvgTreeSequence: +def add_class(attrs_dict, classes_str): + """Adds the classes_str to the 'class' key in attrs_dict, or creates it""" + try: + attrs_dict["class"] += " " + classes_str + except KeyError: + attrs_dict["class"] = classes_str + + +@dataclass +class Plotbox: + total_size: list + pad_top: float + pad_left: float + pad_bottom: float + pad_right: float + + @property + def max_x(self): + return self.total_size[0] + + @property + def max_y(self): + return self.total_size[1] + + @property + def top(self): # Alias for consistency with top & bottom + return self.pad_top + + @property + def left(self): # Alias for consistency with top & bottom + return self.pad_left + + @property + def bottom(self): + return self.max_y - self.pad_bottom + + @property + def right(self): + return self.max_x - self.pad_right + + @property + def width(self): + return self.right - self.left + + @property + def height(self): + return self.bottom - self.top + + def __post_init__(self): + if self.width < 1 or self.height < 1: + raise ValueError("Image size too small to fit") + + def draw(self, dwg, add_to, colour="grey"): + # used for debugging + add_to.add( + dwg.rect( + (0, 0), + (self.max_x, self.max_y), + fill="white", + fill_opacity=0, + stroke=colour, + stroke_dasharray="15,15", + class_="outer_plotbox", + ) + ) + add_to.add( + dwg.rect( + (self.left, self.top), + (self.width, self.height), + fill="white", + fill_opacity=0, + stroke=colour, + stroke_dasharray="5,5", + class_="inner_plotbox", + ) + ) + + +class SvgPlot: + """ The base class for plotting either a tree or a tree sequence as an SVG file""" + + standard_style = ( + ".tree-sequence .background path {fill: #808080; fill-opacity:0}" + ".tree-sequence .background path:nth-child(odd) {fill-opacity:.1}" + ".axes {font-size: 14px}" + ".x-axis .tick .lab {font-weight: bold}" + ".axes, .tree {font-size: 14px; text-anchor:middle}" + ".y-axis line.grid {stroke: #FAFAFA}" + ".y-axis > .lab text {transform: translateX(0.8em) rotate(-90deg)}" + ".x-axis .tick g {transform: translateY(0.9em)}" + ".x-axis > .lab text {transform: translateY(-0.8em)}" + ".axes line, .edge {stroke:black; fill:none}" + ".node > .sym {fill: black; stroke: none}" + ".site > .sym {stroke: black}" + ".mut text {fill: red; font-style: italic}" + ".mut line {fill: none; stroke: none}" # Default hide mut line to expose edges + ".mut .sym {fill: none; stroke: red}" + ".node .mut .sym {stroke-width: 1.5px}" + ".tree text, .tree-sequence text {dominant-baseline: central}" + ".plotbox .lab.lft {text-anchor: end}" + ".plotbox .lab.rgt {text-anchor: start}" + ) + + # TODO: we may want to make some of the constants below into parameters + text_height = 14 # May want to calculate this based on a font size + line_height = text_height * 1.2 # allowing padding above and below a line + root_branch_fraction = ( + 1 / 8 + ) # Rel. root branch len (unless it has a timed mutation) + default_tick_length = 5 + default_tick_length_site = 10 + # Placement of the axes lines within the padding - not used unless axis is plotted + default_x_axis_offset = 20 + default_y_axis_offset = 40 + + def __init__( + self, + ts, + size, + root_svg_attributes, + style, + svg_class, + tree_height_scale, + x_axis=None, + y_axis=None, + x_label=None, + y_label=None, + debug_box=None, + ): + """ + Creates self.drawing, an svgwrite.Drawing object for further use, and populates + it with a stylesheet and base group. The root_groups will be populated with + items that can be accessed from the ourside, such as the plotbox, axes, etc. + """ + self.ts = ts + self.image_size = size + self.svg_class = svg_class + if root_svg_attributes is None: + root_svg_attributes = {} + self.root_svg_attributes = root_svg_attributes + dwg = svgwrite.Drawing(size=size, debug=True, **root_svg_attributes) + # Put all styles in a single stylesheet (required for Inkscape 0.92) + style = self.standard_style + ("" if style is None else style) + dwg.defs.add(dwg.style(style)) + self.dwg_base = dwg.add(dwg.g(class_=svg_class)) + self.root_groups = {} + self.debug_box = debug_box + self.drawing = dwg + self.tree_height_scale = check_tree_height_scale(tree_height_scale) + self.y_axis = y_axis + self.x_axis = x_axis + if x_label is None and x_axis: + x_label = "Genome position" + if y_label is None and y_axis: + if tree_height_scale == "rank": + y_label = "Ranked node time" + else: + y_label = "Time" + self.x_label = x_label + self.y_label = y_label + + def get_plotbox(self): + """ + Get the svgwrite plotbox (contains the tree(s) but not axes etc), creating it + if necessary. + """ + if "plotbox" not in self.root_groups: + dwg = self.drawing + self.root_groups["plotbox"] = self.dwg_base.add(dwg.g(class_="plotbox")) + return self.root_groups["plotbox"] + + def add_text_in_group(self, text, add_to, pos, group_class=None, **kwargs): + """ + Add the text to the elem within a group; allows text rotations to work smoothly, + otherwise, if x & y parameters are used to position text, rotations applied to + the text tag occur around the (0,0) point of the containing group + """ + dwg = self.drawing + group_attributes = {"transform": f"translate({rnd(pos[0])},{rnd(pos[1])})"} + if group_class is not None: + group_attributes["class_"] = group_class + grp = add_to.add(dwg.g(**group_attributes)) + grp.add(dwg.text(text, **kwargs)) + + def set_spacing(self, top=0, left=0, bottom=0, right=0): + """ + Set edges, but allow space for axes etc + """ + self.x_axis_offset = self.default_x_axis_offset + self.y_axis_offset = self.default_y_axis_offset + if self.x_label: + self.x_axis_offset += self.line_height + if self.y_label: + self.y_axis_offset += self.line_height + if self.x_axis: + bottom += self.x_axis_offset + if self.y_axis: + left = self.y_axis_offset # Override user-provided, so y-axis is at x=0 + self.plotbox = Plotbox(self.image_size, top, left, bottom, right) + if self.debug_box: + self.root_groups["debug"] = self.dwg_base.add( + self.drawing.g(class_="debug") + ) + self.plotbox.draw(self.drawing, self.root_groups["debug"]) + + def get_axes(self): + if "axes" not in self.root_groups: + self.root_groups["axes"] = self.dwg_base.add(self.drawing.g(class_="axes")) + return self.root_groups["axes"] + + def draw_x_axis( + self, + tick_positions=None, # np.array of ax ticks below (+ above if sites is None) + tick_labels=None, # Tick labels below axis. If None, use the position value + tick_length_lower=default_tick_length, + tick_length_upper=None, # If None, use the same as tick_length_lower + sites=None, # An iterator over site objects to plot as ticks above the x axis + ): + if not self.x_axis and not self.x_label: + return + dwg = self.drawing + axes = self.get_axes() + x_axis = axes.add(dwg.g(class_="x-axis")) + if self.x_label: + self.add_text_in_group( + self.x_label, + x_axis, + pos=((self.plotbox.left + self.plotbox.right) / 2, self.plotbox.max_y), + group_class="lab", + text_anchor="middle", + ) + if self.x_axis: + if tick_length_upper is None: + tick_length_upper = tick_length_lower + y = rnd(self.plotbox.max_y - self.x_axis_offset) + x_axis.add(dwg.line((self.plotbox.left, y), (self.plotbox.right, y))) + if tick_positions is not None: + if tick_labels is None or isinstance(tick_labels, np.ndarray): + if tick_labels is None: + tick_labels = tick_positions + integer_ticks = np.all(np.round(tick_labels) == tick_labels) + label_precision = 0 if integer_ticks else 2 + tick_labels = [f"{lab:.{label_precision}f}" for lab in tick_labels] + + upper_length = -tick_length_upper if sites is None else 0 + for pos, lab in itertools.zip_longest(tick_positions, tick_labels): + tick = x_axis.add( + dwg.g( + class_="tick", + transform=f"translate({rnd(self.x_transform(pos))} {y})", + ) + ) + tick.add( + dwg.line((0, rnd(upper_length)), (0, rnd(tick_length_lower))) + ) + self.add_text_in_group( + lab, tick, pos=(0, tick_length_lower), group_class="lab" + ) + if sites is not None: + # Add sites as upper chevrons + for s in sites: + x = self.x_transform(s.position) + site = x_axis.add( + dwg.g( + class_=f"site s{s.id}", transform=f"translate({rnd(x)} {y})" + ) + ) + site.add( + dwg.line((0, 0), (0, rnd(-tick_length_upper)), class_="sym") + ) + for i, m in enumerate(reversed(s.mutations)): + mut = dwg.g(class_=f"mut m{m.id}") + h = -i * 4 - 1.5 + w = tick_length_upper / 4 + mut.add( + dwg.polyline( + [ + (rnd(w), rnd(h - 2 * w)), + (0, rnd(h)), + (rnd(-w), rnd(h - 2 * w)), + ], + class_="sym", + ) + ) + site.add(mut) + + def draw_y_axis( + self, + upper=None, # In plot coords + lower=None, # In plot coords + tick_positions=None, + tick_length_left=default_tick_length, + gridlines=None, + ): + if not self.y_axis and not self.y_label: + return + if upper is None: + upper = self.plotbox.top + if lower is None: + lower = self.plotbox.bottom + dwg = self.drawing + x = rnd(self.y_axis_offset) + axes = self.get_axes() + y_axis = axes.add(dwg.g(class_="y-axis")) + if self.y_label: + self.add_text_in_group( + self.y_label, + y_axis, + pos=(0, (upper + lower) / 2), + group_class="lab", + text_anchor="middle", + ) + if self.y_axis: + y_axis.add(dwg.line((x, rnd(lower)), (x, rnd(upper)))) + if tick_positions is not None: + for pos in tick_positions: + tick = y_axis.add( + dwg.g( + class_="tick", + transform=f"translate({x} {rnd(self.y_transform(pos))})", + ) + ) + if gridlines: + tick.add( + dwg.line( + (0, 0), (rnd(self.plotbox.right - x), 0), class_="grid" + ) + ) + tick.add(dwg.line((0, 0), (rnd(-tick_length_left), 0))) + self.add_text_in_group( + f"{pos:.2f}", + tick, + pos=(rnd(-tick_length_left), 0), + group_class="lab", + text_anchor="end", + ) + + def x_transform(self, x): + raise NotImplementedError( + "No transform func defined for genome pos -> plot coords" + ) + + def y_transform(self, y): + raise NotImplementedError( + "No transform func defined for tree height -> plot pos" + ) + + +class SvgTreeSequence(SvgPlot): """ A class to draw a tree sequence in SVG format. @@ -244,45 +593,51 @@ class SvgTreeSequence: def __init__( self, ts, - size=None, - x_scale=None, - tree_height_scale=None, + size, + x_scale, + tree_height_scale, + node_labels, + mutation_labels, + root_svg_attributes, + style, + order, + force_root_branch, + symbol_size, + x_axis, + y_axis, + x_label, + y_label, + y_ticks, + y_gridlines, max_tree_height=None, - node_labels=None, - mutation_labels=None, node_attrs=None, mutation_attrs=None, edge_attrs=None, node_label_attrs=None, mutation_label_attrs=None, - root_svg_attributes=None, - style=None, - order=None, - force_root_branch=None, - symbol_size=None, - x_label=None, + **kwargs, ): - self.ts = ts if size is None: size = (200 * ts.num_trees, 200) - x_scale = check_x_scale(x_scale) - if root_svg_attributes is None: - root_svg_attributes = {} if max_tree_height is None: max_tree_height = "ts" - self.image_size = size - dwg = svgwrite.Drawing(size=self.image_size, debug=True, **root_svg_attributes) - self.drawing = dwg - style = SvgTree.standard_style + ("" if style is None else style) - dwg.defs.add(dwg.style(style)) - root_group = dwg.add(dwg.g(class_="tree-sequence")) - if x_scale == "physical": - background = root_group.add(dwg.g(class_="background")) - axis_top_pad = 15 - tick_len = (0, 5) - else: - axis_top_pad = 5 - tick_len = (5, 5) + # X axis shown by default + if x_axis is None: + x_axis = True + super().__init__( + ts, + size, + root_svg_attributes, + style, + svg_class="tree-sequence", + tree_height_scale=tree_height_scale, + x_axis=x_axis, + y_axis=y_axis, + x_label=x_label, + y_label=y_label, + **kwargs, + ) + x_scale = check_x_scale(x_scale) if node_labels is None: node_labels = {u: str(u) for u in range(ts.num_nodes)} if force_root_branch is None: @@ -291,217 +646,207 @@ def __init__( for tree in ts.trees() ) # TODO add general padding arguments following matplotlib's terminology. - self.axes_x_offset = 15 - self.axes_y_offset = 20 if x_label is None else 34 - self.treebox_x_offset = self.axes_x_offset + 5 - self.treebox_y_offset = self.axes_y_offset + axis_top_pad - treebox_width = size[0] - 2 * self.treebox_x_offset - treebox_height = size[1] - self.treebox_y_offset - tree_width = treebox_width / ts.num_trees + self.set_spacing(top=0, left=20, bottom=15, right=20) svg_trees = [ SvgTree( tree, - (tree_width, treebox_height), + (self.plotbox.width / ts.num_trees, self.plotbox.height), + tree_height_scale=tree_height_scale, node_labels=node_labels, mutation_labels=mutation_labels, - tree_height_scale=tree_height_scale, + order=order, + force_root_branch=force_root_branch, + symbol_size=symbol_size, max_tree_height=max_tree_height, node_attrs=node_attrs, + mutation_attrs=mutation_attrs, edge_attrs=edge_attrs, node_label_attrs=node_label_attrs, - mutation_attrs=mutation_attrs, mutation_label_attrs=mutation_label_attrs, - order=order, - force_root_branch=force_root_branch, - symbol_size=symbol_size, + # Do not plot axes on these subplots + **kwargs, # pass though e.g. debug boxes ) for tree in ts.trees() ] + y = self.plotbox.top + self.tree_plotbox = svg_trees[0].plotbox + self.draw_x_axis( + x_scale, + tick_length_lower=self.default_tick_length, # TODO - parameterize + tick_length_upper=self.default_tick_length_site, # TODO - parameterize + ) + y_low = self.tree_plotbox.bottom + if y_axis is not None: + self.y_transform = lambda x: svg_trees[0].y_transform(x) + y + for svg_tree in svg_trees: + if self.y_transform(1.234) != svg_tree.y_transform(1.234) + y: + # Slight hack: check an arbitrary value is transformed identically + raise ValueError( + "Can't draw a tree sequence Y axis for trees of varying yscales" + ) + y_low = self.y_transform( + 0 + ) # if poss use the zero point for lowest axis pos + ytimes = np.unique(ts.tables.nodes.time) + if self.tree_height_scale == "rank": + ytimes = np.arange(len(ytimes)) + y_ticks = check_ticks(y_ticks, ytimes) + self.draw_y_axis( + upper=self.tree_plotbox.top, + lower=y_low, + tick_positions=y_ticks, + tick_length_left=self.default_tick_length, + gridlines=y_gridlines, + ) - ticks = [] # svg_x_pos of drawn trees, svg_x_pos of breakpoints, & labels - y = 0 - trees = root_group.add(dwg.g(class_="trees")) - drawing_scale = float(tree_width * ts.num_trees) / ts.sequence_length - tree_x = self.treebox_x_offset - break_x = self.treebox_x_offset - - for svg_tree, tree in zip(svg_trees, ts.trees()): - treebox = trees.add( - dwg.g( - class_=f"treebox t{tree.index}", - transform=f"translate({rnd(tree_x)} {rnd(y)})", + tree_x = self.plotbox.left + trees = self.get_plotbox() # Top-level TS plotbox contains all trees + trees["class"] = trees["class"] + " trees" + for svg_tree in svg_trees: + tree = trees.add( + self.drawing.g( + class_=svg_tree.svg_class, transform=f"translate({rnd(tree_x)} {y})" ) ) - treebox.add(svg_tree.root_group) - ticks.append((tree_x, break_x, tree.interval[0])) - tree_x += tree_width - break_x += tree.span * drawing_scale - ticks.append((tree_x, break_x, ts.sequence_length)) - - # # Debug --- draw the tree and axes boxes - # w = self.image_size[0] - 2 * self.treebox_x_offset - # h = self.image_size[1] - 2 * self.treebox_y_offset - # dwg.add(dwg.rect((self.treebox_x_offset, self.treebox_y_offset), (w, h), - # fill="white", fill_opacity=0, stroke="black", stroke_dasharray="15,15")) - # w = self.image_size[0] - 2 * self.axes_x_offset - # h = self.image_size[1] - 2 * self.axes_y_offset - # dwg.add(dwg.rect((self.axes_x_offset, self.axes_y_offset), (w, h), - # fill="white", fill_opacity=0, stroke="black", stroke_dasharray="5,5")) - - axes_left = self.treebox_x_offset - axes_right = self.image_size[0] - self.treebox_x_offset - y = self.image_size[1] - self.axes_y_offset - axis = root_group.add(dwg.g(class_="axis")) - axis.add(dwg.line((axes_left, y), (axes_right, y))) - integer_ticks = all(round(label) == label for _, _, label in ticks) - label_precision = 0 if integer_ticks else 2 - for i, tick in enumerate(ticks): - tree_x, break_x, genome_coord = tick - if x_scale == "treewise": - x = tree_x - elif x_scale == "physical": - # Shift diagonal lines between tree & axis into the treebox a little - backgd_pad_y = axis_top_pad + svg_trees[0].treebox_y_offset - x = break_x - if i > 0 and i % 2 == 1: - # draw an alternating grey background - prev_tree_x, prev_break_x, _ = ticks[i - 1] - background.add( + for svg_items in svg_tree.root_groups.values(): + tree.add(svg_items) + tree_x += svg_tree.image_size[0] + assert self.tree_plotbox == svg_tree.plotbox + + def draw_x_axis( + self, + x_scale, + tick_length_lower=SvgPlot.default_tick_length, + tick_length_upper=SvgPlot.default_tick_length_site, + ): + """ + Add extra functionality to the original draw_x_axis method in SvgPlot, mainly + to account for the background shading that is displayed in a tree sequence + """ + if not self.x_axis and not self.x_label: + return + if x_scale == "physical": + breaks = self.ts.breakpoints(as_array=True) + if self.x_axis: + # Assume the trees are simply concatenated end-to-end + self.x_transform = ( + lambda x: self.plotbox.left + + x / self.ts.sequence_length * self.plotbox.width + ) + plot_breaks = self.x_transform(breaks) + dwg = self.drawing + + # For tree sequences, we need to add on the background shaded regions + self.root_groups["background"] = self.dwg_base.add( + dwg.g(class_="background") + ) + # plotbox_bottom_padding += 10 # extra space for the diagonal lines + y = self.image_size[1] - self.x_axis_offset + for i in range(1, len(breaks)): + break_x = plot_breaks[i] + prev_break_x = plot_breaks[i - 1] + tree_x = i * self.tree_plotbox.max_x + self.plotbox.left + prev_tree_x = (i - 1) * self.tree_plotbox.max_x + self.plotbox.left + # Shift diagonal lines between tree & axis into the treebox a little + diag_height = y - ( + self.plotbox.bottom - self.tree_plotbox.pad_bottom + ) + self.root_groups["background"].add( dwg.path( f"M{rnd(prev_tree_x):g},0 " f"l{rnd(tree_x-prev_tree_x):g},0 " - f"l0,{rnd(y - backgd_pad_y):g} " - f"l{rnd(break_x-tree_x):g},{rnd(backgd_pad_y):g} " + f"l0,{rnd(y - diag_height):g} " + f"l{rnd(break_x-tree_x):g},{rnd(diag_height):g} " # NB for curves try "c0,{1} {0},0 {0},{1}" instead of above - f"l0,{rnd(tick_len[1]):g} " + f"l0,{rnd(tick_length_lower):g} " f"l{rnd(prev_break_x-break_x):g},0 " - f"l0,{rnd(-tick_len[1]):g} " - f"l{rnd(prev_tree_x-prev_break_x):g},{rnd(-backgd_pad_y):g} " + f"l0,{rnd(-tick_length_lower):g} " + f"l{rnd(prev_tree_x-prev_break_x):g},{rnd(-diag_height):g} " # NB for curves try "c0,{1} {0},0 {0},{1}" instead of above - f"l0,{rnd(backgd_pad_y - y):g}z", + f"l0,{rnd(diag_height - y):g}z", ) ) - - axis.add( - dwg.line( - (rnd(x), rnd(y - tick_len[0])), - (rnd(x), rnd(y + tick_len[1])), - class_="tick", - ) - ) - add_text_in_group( - dwg, - axis, - x, - y + 18, - f"{genome_coord:.{label_precision}f}", - class_="x-tick-label", - text_anchor="middle", + super().draw_x_axis( + tick_positions=breaks, + tick_length_lower=tick_length_lower, + tick_length_upper=tick_length_upper, + sites=self.ts.sites(), ) - if x_scale == "physical": - # Add sites as upper chevrons - for s in ts.sites(): - x = axes_left + s.position * drawing_scale - site = axis.add(dwg.g(class_=f"site s{s.id}")) - site.add( - dwg.path( - [("M", (rnd(x), rnd(y))), ("v", rnd(-2 * tick_len[1]))], - class_="sym", - ) - ) - for i, m in enumerate(reversed(s.mutations)): - mut = dwg.g(class_=f"mut m{m.id}") - ypos = y - i * 4 - 1.5 - mut.add( - dwg.polyline( - [ - (rnd(x - tick_len[1] / 2), rnd(ypos - tick_len[1])), - (rnd(x), rnd(ypos)), - (rnd(x + tick_len[1] / 2), rnd(ypos - tick_len[1])), - ], - class_="sym", - ) - ) - site.add(mut) - - if x_label is not None: - add_text_in_group( - dwg, - axis, - (axes_left + axes_right) / 2, - y + 30, - x_label, - class_="x-label", - text_anchor="middle", + + else: + # No background shading needed if x_scale is "treewise" + n = self.ts.num_trees + self.x_transform = lambda x: self.plotbox.left + x * self.plotbox.width / n + super().draw_x_axis( + tick_positions=np.arange(n + 1), + tick_labels=self.ts.breakpoints(as_array=True), + tick_length_lower=tick_length_lower, ) -class SvgTree: +class SvgTree(SvgPlot): """ A class to draw a tree in SVG format. See :meth:`Tree.draw_svg` for a description of usage and frequently used parameters. """ - standard_style = ( - ".tree-sequence .background path {fill: #808080; fill-opacity:.1}" - ".tree-sequence .axis {font-size: 14px}" - ".tree-sequence .x-tick-label {font-weight: bold}" - ".tree-sequence .axis, .tree {font-size: 14px; text-anchor:middle}" - ".tree-sequence .axis line, .edge {stroke:black; fill:none}" - ".node > .sym {fill: black; stroke: none}" - ".site > .sym {stroke: black}" - ".mut text {fill: red; font-style: italic}" - ".mut line {fill: none; stroke: none}" # Default hide mutn line to expose edges - ".mut .sym {fill: none; stroke: red}" - ".node .mut .sym {stroke-width: 1.5px}" - ".tree text {dominant-baseline: middle}" # NB: not inherited in css 1.1 - ".tree .lab.lft {text-anchor: end}" - ".tree .lab.rgt {text-anchor: start}" - ) - - @staticmethod - def add_class(attrs_dict, classes_str): - """Adds the classes_str to the 'class' key in attrs_dict, or creates it""" - try: - attrs_dict["class"] += " " + classes_str - except KeyError: - attrs_dict["class"] = classes_str - def __init__( self, tree, size=None, - tree_height_scale=None, max_tree_height=None, node_labels=None, mutation_labels=None, - node_attrs=None, - mutation_attrs=None, - edge_attrs=None, - node_label_attrs=None, - mutation_label_attrs=None, root_svg_attributes=None, style=None, order=None, force_root_branch=None, symbol_size=None, + x_axis=None, + y_axis=None, + x_label=None, + y_label=None, + y_ticks=None, + y_gridlines=None, + tree_height_scale=None, + node_attrs=None, + mutation_attrs=None, + edge_attrs=None, + node_label_attrs=None, + mutation_label_attrs=None, + **kwargs, ): - self.tree = tree - self.ts = tree.tree_sequence - self.traversal_order = check_order(order) if size is None: size = (200, 200) - self.image_size = size - if root_svg_attributes is None: - root_svg_attributes = {} - self.root_svg_attributes = root_svg_attributes if symbol_size is None: symbol_size = 6 self.symbol_size = symbol_size - self.drawing = self.setup_drawing(style) + super().__init__( + tree.tree_sequence, + size, + root_svg_attributes, + style, + svg_class=f"tree t{tree.index}", + tree_height_scale=tree_height_scale, + x_axis=x_axis, + y_axis=y_axis, + x_label=x_label, + y_label=y_label, + **kwargs, + ) + self.tree = tree + self.traversal_order = check_order(order) + + # Create some instance variables for later use in plotting self.node_mutations = collections.defaultdict(list) + self.edge_attrs = {} + self.node_attrs = {} + self.node_label_attrs = {} + self.mutation_attrs = {} + self.mutation_label_attrs = {} self.mutations_over_roots = False + # mutations collected per node nodes = set(tree.nodes()) unplotted = [] for site in tree.sites(): @@ -517,16 +862,9 @@ def __init__( f"Mutations {unplotted} are above nodes which are not present in the " "displayed tree, so are not plotted on the topology." ) - self.treebox_x_offset = 10 - self.treebox_y_offset = 10 # Amount at top and bottom to leave blank - self.treebox_width = size[0] - 2 * self.treebox_x_offset - self.assign_y_coordinates(tree_height_scale, max_tree_height, force_root_branch) - self.assign_x_coordinates(tree, self.treebox_x_offset, self.treebox_width) - self.edge_attrs = {} - self.node_attrs = {} - self.node_label_attrs = {} - symbol_size = "{:g}".format(rnd(self.symbol_size)) - half_symbol_size = "{:g}".format(rnd(self.symbol_size / 2)) + # attributes for symbols + half_symbol_size = "{:g}".format(rnd(symbol_size / 2)) + symbol_size = "{:g}".format(rnd(symbol_size)) for u in tree.nodes(): self.edge_attrs[u] = {} if edge_attrs is not None and u in edge_attrs: @@ -542,19 +880,16 @@ def __init__( self.node_attrs[u] = {"center": (0, 0), "r": half_symbol_size} if node_attrs is not None and u in node_attrs: self.node_attrs[u].update(node_attrs[u]) - self.add_class(self.node_attrs[u], "sym") # class 'sym' for symbol + add_class(self.node_attrs[u], "sym") # class 'sym' for symbol label = "" if node_labels is None: label = str(u) elif u in node_labels: label = str(node_labels[u]) self.node_label_attrs[u] = {"text": label} - self.add_class(self.node_label_attrs[u], "lab") # class 'lab' for label + add_class(self.node_label_attrs[u], "lab") # class 'lab' for label if node_label_attrs is not None and u in node_label_attrs: self.node_label_attrs[u].update(node_label_attrs[u]) - - self.mutation_attrs = {} - self.mutation_label_attrs = {} for site in tree.sites(): for mutation in site.mutations: m = mutation.id @@ -566,7 +901,7 @@ def __init__( } if mutation_attrs is not None and m in mutation_attrs: self.mutation_attrs[m].update(mutation_attrs[m]) - self.add_class(self.mutation_attrs[m], "sym") # class 'sym' for symbol + add_class(self.mutation_attrs[m], "sym") # class 'sym' for symbol label = "" if mutation_labels is None: label = str(m) @@ -575,85 +910,101 @@ def __init__( self.mutation_label_attrs[m] = {"text": label} if mutation_label_attrs is not None and m in mutation_label_attrs: self.mutation_label_attrs[m].update(mutation_label_attrs[m]) - self.add_class(self.mutation_label_attrs[m], "lab") - self.draw() - - def setup_drawing(self, style): - "Return an svgwrite.Drawing object for further use" - dwg = svgwrite.Drawing( - size=self.image_size, debug=True, **self.root_svg_attributes + add_class(self.mutation_label_attrs[m], "lab") + + self.set_spacing(top=10, left=20, bottom=10, right=20) + self.assign_y_coordinates(max_tree_height, force_root_branch) + self.assign_x_coordinates() + self.draw_x_axis( + tick_positions=np.array(tree.interval), + tick_length_lower=self.default_tick_length, # TODO - parameterize + tick_length_upper=self.default_tick_length_site, # TODO - parameterize + sites=tree.sites(), ) - # Put all styles in a single stylesheet (required for Inkscape 0.92) - style = SvgTree.standard_style + ("" if style is None else style) - dwg.defs.add(dwg.style(style)) - tree_class = f"tree t{self.tree.index}" - self.root_group = dwg.add(dwg.g(class_=tree_class)) - return dwg + self.draw_y_axis( + lower=self.y_transform(0), + tick_positions=check_ticks(y_ticks, set(self.node_height.values())), + tick_length_left=self.default_tick_length, + gridlines=y_gridlines, + ) + self.draw_tree() - def process_mutations(self, mut_node, lower_bound, upper_bound, ignore_times=False): + def process_mutations_over_node(self, u, low_bound, high_bound, ignore_times=False): """ - Sort the self.node_mutations array for a given mut_node in reverse time order, - returning the oldest time. - The main complication is with UNKNOWN_TIME values: we replace those with - times spaced between the lower and upper bounds + Sort the self.node_mutations array for a given node ``u`` in reverse time order. + The main complication is with UNKNOWN_TIME values: we replace these with times + spaced between the low & high bounds (this is always done if ignore_times=True). + We do not currently allow a mix of known & unknown mutation times in a tree + sequence, which makes the logic easy. If we were to allow it, more complex + logic can be neatly encapsulated in this method. """ - mutations = self.node_mutations[mut_node] + mutations = self.node_mutations[u] time_unknown = [util.is_unknown_time(m.time) for m in mutations] if all(time_unknown) or ignore_times is True: # sort by site then within site by parent: will end up with oldest first mutations.sort(key=operator.attrgetter("site", "parent")) - diff = upper_bound - lower_bound + diff = high_bound - low_bound for i in range(len(mutations)): - mutations[i].time = upper_bound - diff * (i + 1) / (len(mutations) + 1) + mutations[i].time = high_bound - diff * (i + 1) / (len(mutations) + 1) else: assert not any(time_unknown) mutations.sort(key=operator.attrgetter("time"), reverse=True) - return mutations[0].time def assign_y_coordinates( - self, tree_height_scale, max_tree_height, force_root_branch + self, + max_tree_height, + force_root_branch, + bottom_space=SvgPlot.line_height, + top_space=SvgPlot.line_height, ): - tree_height_scale = check_tree_height_scale(tree_height_scale) - height = self.image_size[1] + """ + Create a self.node_height dict, a self.y_transform func and + self.min_root_branch_plot_length for use in plotting. Allow extra space within + the plotbox, at the bottom for leaf labels, and (potentially, if no root + branches are plotted) above the topmost root node for root labels. + """ max_tree_height = check_max_tree_height( - max_tree_height, tree_height_scale != "rank" + max_tree_height, self.tree_height_scale != "rank" ) node_time = self.ts.tables.nodes.time mut_time = self.ts.tables.mutations.time - transform = identity - self.min_root_branch_length = 0 - if tree_height_scale == "rank": + root_branch_length = 0 + if self.tree_height_scale == "rank": if max_tree_height == "tree": # We only rank the times within the tree in this case. t = np.zeros_like(node_time) for u in self.tree.nodes(): t[u] = node_time[u] node_time = t - depth = {t: 2 * j for j, t in enumerate(np.unique(node_time))} + times = np.unique(node_time[node_time <= self.ts.max_root_time]) + max_node_height = len(times) + depth = {t: j for j, t in enumerate(times)} if self.mutations_over_roots or force_root_branch: - self.min_root_branch_length = 2 # Will get scaled later - max_tree_height = max(depth.values()) + self.min_root_branch_length + root_branch_length = 1 # Will get scaled later + max_tree_height = max(depth.values()) + root_branch_length # In pathological cases, all the roots are at 0 if max_tree_height == 0: max_tree_height = 1 - node_height = {u: depth[node_time[u]] for u in self.tree.nodes()} + self.node_height = {u: depth[node_time[u]] for u in self.tree.nodes()} for u in self.node_mutations.keys(): parent = self.tree.parent(u) if parent == NULL: - top = node_height[u] + self.min_root_branch_length + top = self.node_height[u] + root_branch_length else: - top = node_height[parent] - self.process_mutations(u, node_height[u], top, ignore_times=True) + top = self.node_height[parent] + self.process_mutations_over_node( + u, self.node_height[u], top, ignore_times=True + ) else: - assert tree_height_scale in ["time", "log_time"] - node_height = {u: node_time[u] for u in self.tree.nodes()} + assert self.tree_height_scale in ["time", "log_time"] + self.node_height = {u: node_time[u] for u in self.tree.nodes()} if max_tree_height == "tree": - max_node_height = max(node_height.values()) + max_node_height = max(self.node_height.values()) max_mut_height = np.nanmax( [0] + [mut.time for m in self.node_mutations.values() for mut in m] ) else: - max_node_height = np.max(node_time) + max_node_height = self.ts.max_root_time max_mut_height = np.nanmax(np.append(mut_time, 0)) max_tree_height = max(max_node_height, max_mut_height) # Reuse variable # In pathological cases, all the roots are at 0 @@ -661,64 +1012,66 @@ def assign_y_coordinates( max_tree_height = 1 if self.mutations_over_roots or force_root_branch: - # TODO - what should the minimum root branch length be in this case? We - # take an eighth of the oldest time. This may be made longer by old muts - self.min_root_branch_length = max_tree_height / 8 - # May need to allow for this in max_tree_height - if max_node_height + self.min_root_branch_length > max_tree_height: - max_tree_height = max_node_height + self.min_root_branch_length + # Define a minimum root branch length, after transformation if necessary + if self.tree_height_scale != "log_time": + root_branch_length = max_tree_height * self.root_branch_fraction + else: + log_height = np.log(max_tree_height + 1) + root_branch_length = ( + np.exp(log_height * (1 + self.root_branch_fraction)) + - 1 + - max_tree_height + ) + # If necessary, allow for this extra branch in max_tree_height + if max_node_height + root_branch_length > max_tree_height: + max_tree_height = max_node_height + root_branch_length for u in self.node_mutations.keys(): parent = self.tree.parent(u) if parent == NULL: # This is a root: if muts have no times we must specify an upper time - top = node_height[u] + self.min_root_branch_length + top = self.node_height[u] + root_branch_length else: - top = node_height[parent] - self.process_mutations(u, node_height[u], top) - - if tree_height_scale == "log_time": - transform = log_transform + top = self.node_height[parent] + self.process_mutations_over_node(u, self.node_height[u], top) assert float(max_tree_height) == max_tree_height - # TODO should make this a parameter somewhere. This is padding above the top and - # below the bottom of the tree to keep the node labels within the treebox. Top is - # not needed if we have a root branch which pushes the whole tree + labels down - top_label_pad = 0 if self.min_root_branch_length > 0 else 18 - bottom_label_pad = 18 - y_top = top_label_pad + self.treebox_y_offset - height = self.image_size[1] - padding_numerator = height - y_top - bottom_label_pad - self.treebox_y_offset - y_scale = padding_numerator / transform(max_tree_height) - max_node = max(node_height.keys(), key=node_height.get) + # Add extra space above the top and below the bottom of the tree to keep the + # node labels within the plotbox (but top label space not needed if the + # existence of a root branch pushes the whole tree + labels downwards anyway) + top_space = 0 if root_branch_length > 0 else top_space + zero_pos = self.plotbox.height + self.plotbox.top - bottom_space + padding_numerator = self.plotbox.height - top_space - bottom_space + if padding_numerator < 0: + raise ValueError("Image size too small to allow space to plot tree") # Transform the y values into plot space (inverted y with 0 at the top of screen) - node_height["root_branch"] = node_height[max_node] + self.min_root_branch_length - self.node_y_coord_map = { - u: y_top + (padding_numerator - y_scale * transform(h)) - for u, h in node_height.items() - } - self.mut_y_coord_map = { - m.id: y_top + (padding_numerator - y_scale * transform(m.time)) - for _, mutations in self.node_mutations.items() - for m in mutations - } - self.min_root_branch_length = ( - self.node_y_coord_map[max_node] - self.node_y_coord_map["root_branch"] - ) - # Here we could also define and transform the tickmarks on the Y axis if required - - def assign_x_coordinates(self, tree, x_start, width): - num_leaves = len(list(tree.leaves())) - x_scale = width / (num_leaves + 1) + if self.tree_height_scale == "log_time": + # add 1 so that don't reach log(0) = -inf error. + # just shifts entire timeset by 1 unit so shouldn't affect anything + y_scale = padding_numerator / np.log(max_tree_height + 1) + self.y_transform = lambda y: zero_pos - np.log(y + 1) * y_scale + else: + y_scale = padding_numerator / max_tree_height + self.y_transform = lambda y: zero_pos - y * y_scale + + # Calculate default root branch length to use (in plot coords). This is a + # minimum, as branches with deep root mutations could be longer + self.min_root_branch_plot_length = self.y_transform( + max_tree_height + ) - self.y_transform(max_tree_height + root_branch_length) + + def assign_x_coordinates(self): + num_leaves = len(list(self.tree.leaves())) + x_scale = self.plotbox.width / num_leaves node_x_coord_map = {} - leaf_x = x_start - for root in tree.roots: - for u in tree.nodes(root, order=self.traversal_order): - if tree.is_leaf(u): - leaf_x += x_scale + leaf_x = self.plotbox.left + x_scale / 2 + for root in self.tree.roots: + for u in self.tree.nodes(root, order=self.traversal_order): + if self.tree.is_leaf(u): node_x_coord_map[u] = leaf_x + leaf_x += x_scale else: - child_coords = [node_x_coord_map[c] for c in tree.children(u)] + child_coords = [node_x_coord_map[c] for c in self.tree.children(u)] if len(child_coords) == 1: node_x_coord_map[u] = child_coords[0] else: @@ -726,6 +1079,11 @@ def assign_x_coordinates(self, tree, x_start, width): b = max(child_coords) node_x_coord_map[u] = a + (b - a) / 2 self.node_x_coord_map = node_x_coord_map + # Transform is not for nodes but for genome positions + self.x_transform = lambda x: ( + (x - self.tree.interval.left) / self.tree.interval.span * self.plotbox.width + + self.plotbox.left + ) def info_classes(self, focal_node_id): """ @@ -759,10 +1117,10 @@ def info_classes(self, focal_node_id): classes.add(f"s{mutation.site}") return sorted(classes) - def draw(self): + def draw_tree(self): dwg = self.drawing node_x_coord_map = self.node_x_coord_map - node_y_coord_map = self.node_y_coord_map + node_y_coord_map = {u: self.y_transform(h) for u, h in self.node_height.items()} tree = self.tree left_child = get_left_child(tree, self.traversal_order) @@ -774,7 +1132,7 @@ def draw(self): transform=f"translate({rnd(node_x_coord_map[u])} " f"{rnd(node_y_coord_map[u])})", ) - stack.append((u, self.root_group.add(grp))) + stack.append((u, self.get_plotbox().add(grp))) while len(stack) > 0: u, curr_svg_group = stack.pop() pu = node_x_coord_map[u], node_y_coord_map[u] @@ -794,7 +1152,7 @@ def draw(self): # Add edge first => on layer underneath anything else if v != NULL: - self.add_class(self.edge_attrs[u], "edge") + add_class(self.edge_attrs[u], "edge") pv = node_x_coord_map[v], node_y_coord_map[v] dx = pv[0] - pu[0] dy = pv[1] - pu[1] @@ -803,31 +1161,30 @@ def draw(self): ) curr_svg_group.add(path) else: - branch_length = self.min_root_branch_length - if branch_length > 0: - self.add_class(self.edge_attrs[u], "edge") + root_branch_l = self.min_root_branch_plot_length + if root_branch_l > 0: + add_class(self.edge_attrs[u], "edge") if len(self.node_mutations[u]) > 0: - mutation = self.node_mutations[u][ - 0 - ] # Oldest mut on this branch - branch_length = max( - branch_length, - self.node_y_coord_map[u] - - self.mut_y_coord_map[mutation.id], + mutation = self.node_mutations[u][0] # Oldest on this branch + root_branch_l = max( + root_branch_l, + node_y_coord_map[u] - self.y_transform(mutation.time), ) path = dwg.path( - [("M", o), ("V", rnd(-branch_length)), ("H", 0)], + [("M", o), ("V", rnd(-root_branch_l)), ("H", 0)], **self.edge_attrs[u], ) curr_svg_group.add(path) - pv = (pu[0], pu[1] - branch_length) + pv = (pu[0], pu[1] - root_branch_l) # Add mutation symbols + labels for mutation in self.node_mutations[u]: # TODO get rid of these manual positioning tweaks and add them # as offsets the user can access via a transform or something. - dy = self.mut_y_coord_map[mutation.id] - pu[1] + dy = self.y_transform(mutation.time) - pu[1] mutation_class = f"mut m{mutation.id} s{mutation.site}" + if util.is_unknown_time(self.ts.mutation(mutation.id).time): + mutation_class += " unknown_time" mut_group = curr_svg_group.add( dwg.g(class_=mutation_class, transform=f"translate(0 {rnd(dy)})") ) @@ -839,11 +1196,11 @@ def draw(self): # Labels if u == left_child[tree.parent(u)]: mut_label_class = "lft" - transform = "translate(-5 0)" + transform = f"translate(-{rnd(2+self.symbol_size/2)} 0)" else: mut_label_class = "rgt" - transform = "translate(5 0)" - self.add_class(self.mutation_label_attrs[mutation.id], mut_label_class) + transform = f"translate({rnd(2+self.symbol_size/2)} 0)" + add_class(self.mutation_label_attrs[mutation.id], mut_label_class) self.mutation_label_attrs[mutation.id]["transform"] = transform mut_group.add(dwg.text(**self.mutation_label_attrs[mutation.id])) @@ -854,18 +1211,19 @@ def draw(self): else: curr_svg_group.add(dwg.circle(**self.node_attrs[u])) # Labels + node_lab_attr = self.node_label_attrs[u] if tree.is_leaf(u): - self.node_label_attrs[u]["transform"] = "translate(0 12)" - elif tree.parent(u) == NULL and self.min_root_branch_length == 0: - self.node_label_attrs[u]["transform"] = "translate(0 -10)" + node_lab_attr["transform"] = f"translate(0 {self.text_height - 3})" + elif tree.parent(u) == NULL and self.min_root_branch_plot_length == 0: + node_lab_attr["transform"] = f"translate(0 -{self.text_height - 3})" else: if u == left_child[tree.parent(u)]: - self.add_class(self.node_label_attrs[u], "lft") - self.node_label_attrs[u]["transform"] = "translate(-3 -6)" + add_class(node_lab_attr, "lft") + node_lab_attr["transform"] = f"translate(-3 -{self.text_height/2})" else: - self.add_class(self.node_label_attrs[u], "rgt") - self.node_label_attrs[u]["transform"] = "translate(3 -6)" - curr_svg_group.add(dwg.text(**self.node_label_attrs[u])) + add_class(node_lab_attr, "rgt") + node_lab_attr["transform"] = f"translate(3 -{self.text_height/2})" + curr_svg_group.add(dwg.text(**node_lab_attr)) class TextTreeSequence: diff --git a/python/tskit/trees.py b/python/tskit/trees.py index de9fcd8a8b..8fd31388e2 100644 --- a/python/tskit/trees.py +++ b/python/tskit/trees.py @@ -1462,6 +1462,13 @@ def draw_svg( style=None, order=None, force_root_branch=None, + symbol_size=None, + x_axis=None, + y_axis=None, + x_label=None, + y_label=None, + y_ticks=None, + y_gridlines=None, **kwargs, ): """ @@ -1608,6 +1615,24 @@ def draw_svg( :param bool force_root_branch: If ``True`` always plot a branch (edge) above the root(s) in the tree. If ``None`` (default) then only plot such root branches if there is a mutation above a root of the tree. + :param float symbol_size: Change the default size of the node and mutation + plotting symbols. If ``None`` (default) use a standard size. + :param bool x_axis: Should the plot have an X axis line, showing the start and + end position of this tree along the genome. If ``None`` (default) do not + plot an X axis. + :param bool y_axis: Should the plot have an Y axis line, showing time (or + ranked node time if ``tree_height_scale="rank"``). If ``None`` (default) + do not plot a Y axis. + :param str x_label: Place a label under the plot. If ``None`` (default) and + there is an X axis, create and place an appropriate label. + :param str y_label: Place a label to the left of the plot. If ``None`` (default) + and there is a Y axis, create and place an appropriate label. + :param list y_ticks: A list of Y values at which to plot tickmarks (``[]`` + gives no tickmarks). If ``None``, plot one tickmark for each unique + node value. + :param bool y_gridlines: Whether to plot horizontal lines behind the tree + at each y tickmark. If ``None`` (default), only plot gridlines if a list + of ``y_ticks`` has also been given. :return: An SVG representation of a tree. :rtype: str @@ -1623,6 +1648,13 @@ def draw_svg( style=style, order=order, force_root_branch=force_root_branch, + symbol_size=symbol_size, + x_axis=x_axis, + y_axis=y_axis, + x_label=x_label, + y_label=y_label, + y_ticks=y_ticks, + y_gridlines=y_gridlines, **kwargs, ) output = draw.drawing.tostring() @@ -5276,7 +5308,13 @@ def draw_svg( style=None, order=None, force_root_branch=None, + symbol_size=None, + x_axis=None, + y_axis=None, x_label=None, + y_label=None, + y_ticks=None, + y_gridlines=None, **kwargs, ): """ @@ -5341,8 +5379,24 @@ def draw_svg( :param bool force_root_branch: If ``True`` plot a branch (edge) above every tree root in the tree sequence. If ``None`` (default) then only plot such root branches if any root in the tree sequence has a mutation above it. - :param str x_label: A string to display on the X axis, e.g. "Genomic position". - If ``None`` (default) do not label the X axis in this tree sequence. + :param float symbol_size: Change the default size of the node and mutation + plotting symbols. If ``None`` (default) use a standard size. + :param bool x_axis: Should the plot have an X axis line, showing the positions + of trees along the genome. The scale used is determined by the ``x_scale`` + parameter. If ``None`` (default) plot an X axis. + :param bool y_axis: Should the plot have an Y axis line, showing time (or + ranked node time if ``tree_height_scale="rank"``. If ``None`` (default) + do not plot a Y axis. + :param str x_label: Place a label under the plot. If ``None`` (default) and + there is an X axis, create and place an appropriate label. + :param str y_label: Place a label to the left of the plot. If ``None`` (default) + and there is a Y axis, create and place an appropriate label. + :param list y_ticks: A list of Y values at which to plot tickmarks (``[]`` + gives no tickmarks). If ``None``, plot one tickmark for each unique + node value. + :param bool y_gridlines: Whether to plot horizontal lines behind the tree + at each y tickmark. If ``None`` (default), only plot gridlines if a list + of ``y_ticks`` has also been given. :return: An SVG representation of a tree sequence. :rtype: str @@ -5358,7 +5412,13 @@ def draw_svg( style=style, order=order, force_root_branch=force_root_branch, + symbol_size=symbol_size, + x_axis=x_axis, + y_axis=y_axis, x_label=x_label, + y_label=y_label, + y_ticks=y_ticks, + y_gridlines=y_gridlines, **kwargs, ) output = draw.drawing.tostring() From da2cfc241d8e5513a3f040eaf8707eef31eb0b51 Mon Sep 17 00:00:00 2001 From: Yan Wong Date: Fri, 12 Mar 2021 18:25:10 +0000 Subject: [PATCH 5/5] Address review comments --- python/tests/test_drawing.py | 29 ++++++++++++++++++++++------- python/tskit/drawing.py | 15 +++++---------- python/tskit/trees.py | 6 ++---- 3 files changed, 29 insertions(+), 21 deletions(-) diff --git a/python/tests/test_drawing.py b/python/tests/test_drawing.py index 4e7b9ef21b..57aa268d38 100644 --- a/python/tests/test_drawing.py +++ b/python/tests/test_drawing.py @@ -1493,12 +1493,13 @@ def test_nonimplemented_base_class(self): with pytest.raises(NotImplementedError): plot.draw_y_axis(tick_positions=[0]) - def test_nonimplemented_tick_spacing(self): + def test_bad_tick_spacing(self): + # Integer y_ticks to give auto-generated tick locs is not currently implemented t = self.get_binary_tree() - with pytest.raises(NotImplementedError): + with pytest.raises(TypeError): t.draw_svg(y_axis=True, y_ticks=6) ts = self.get_simple_ts() - with pytest.raises(NotImplementedError): + with pytest.raises(TypeError): ts.draw_svg(y_axis=True, y_ticks=6) def test_no_mixed_yscales(self): @@ -1513,12 +1514,26 @@ def test_draw_defaults(self): svg = t.draw_svg() self.verify_basic_svg(svg) - def test_draw_nonbinary(self): - t = self.get_nonbinary_tree() - svg = t.draw() - self.verify_basic_svg(svg) + @pytest.mark.parametrize("y_axis", (True, False)) + @pytest.mark.parametrize("y_label", (True, False)) + @pytest.mark.parametrize( + "tree_height_scale", + ( + "rank", + "time", + ), + ) + @pytest.mark.parametrize("y_ticks", ([], [0, 1], None)) + @pytest.mark.parametrize("y_gridlines", (True, False)) + def test_draw_svg_y_axis_parameter_combos( + self, y_axis, y_label, tree_height_scale, y_ticks, y_gridlines + ): + t = self.get_binary_tree() svg = t.draw_svg() self.verify_basic_svg(svg) + ts = self.get_simple_ts() + svg = ts.draw_svg() + self.verify_basic_svg(svg, width=200 * ts.num_trees) def test_draw_multiroot(self): t = self.get_multiroot_tree() diff --git a/python/tskit/drawing.py b/python/tskit/drawing.py index 0e3d3eb658..87aa48d1ae 100644 --- a/python/tskit/drawing.py +++ b/python/tskit/drawing.py @@ -124,16 +124,13 @@ def check_x_scale(x_scale): def check_ticks(ticks, default_iterable): """ - If y_ticks is iterable, it is a list of tick positions. Otherwise return a default - or None if falsey + This is trivial, but implemented as a function so that later we can implement a tick + locator function, such that e.g. ticks=5 selects ~5 nicely spaced tick locations + (ideally with sensible behaviour for log scales) """ if ticks is None: return default_iterable - try: - iter(ticks) - return ticks - except TypeError: - raise NotImplementedError("Autocalculated tick mark locations not implemented.") + return ticks def rnd(x): @@ -341,9 +338,7 @@ class SvgPlot: # TODO: we may want to make some of the constants below into parameters text_height = 14 # May want to calculate this based on a font size line_height = text_height * 1.2 # allowing padding above and below a line - root_branch_fraction = ( - 1 / 8 - ) # Rel. root branch len (unless it has a timed mutation) + root_branch_fraction = 1 / 8 # Rel root branch len, unless it has a timed mutation default_tick_length = 5 default_tick_length_site = 10 # Placement of the axes lines within the padding - not used unless axis is plotted diff --git a/python/tskit/trees.py b/python/tskit/trees.py index 8fd31388e2..6d03a53fdd 100644 --- a/python/tskit/trees.py +++ b/python/tskit/trees.py @@ -1631,8 +1631,7 @@ def draw_svg( gives no tickmarks). If ``None``, plot one tickmark for each unique node value. :param bool y_gridlines: Whether to plot horizontal lines behind the tree - at each y tickmark. If ``None`` (default), only plot gridlines if a list - of ``y_ticks`` has also been given. + at each y tickmark. :return: An SVG representation of a tree. :rtype: str @@ -5395,8 +5394,7 @@ def draw_svg( gives no tickmarks). If ``None``, plot one tickmark for each unique node value. :param bool y_gridlines: Whether to plot horizontal lines behind the tree - at each y tickmark. If ``None`` (default), only plot gridlines if a list - of ``y_ticks`` has also been given. + at each y tickmark. :return: An SVG representation of a tree sequence. :rtype: str