diff --git a/python/tests/data/svg/internal_sample_ts.svg b/python/tests/data/svg/internal_sample_ts.svg
index fc9c062b3c..4843cd70c4 100644
--- a/python/tests/data/svg/internal_sample_ts.svg
+++ b/python/tests/data/svg/internal_sample_ts.svg
@@ -99,16 +99,6 @@
-
-
-
- 7
-
-
-
-
- 8
-
@@ -123,7 +113,7 @@
- 4
+ 4
@@ -143,7 +133,7 @@
2
- 5
+ 5
@@ -157,7 +147,17 @@
1
- 9
+ 9
+
+
+
+
+ 7
+
+
+
+
+ 8
@@ -187,7 +187,7 @@
4
- 4
+ 4
@@ -207,7 +207,7 @@
- 5
+ 5
@@ -216,7 +216,7 @@
5
- 7
+ 7
@@ -227,16 +227,6 @@
-
-
-
- 7
-
-
-
-
- 8
-
@@ -251,7 +241,7 @@
- 4
+ 4
@@ -266,11 +256,21 @@
- 5
+ 5
- 6
+ 6
+
+
+
+
+ 7
+
+
+
+
+ 8
@@ -290,7 +290,7 @@
- 4
+ 4
@@ -310,11 +310,11 @@
- 5
+ 5
- 7
+ 7
@@ -325,11 +325,6 @@
-
-
-
- 7
-
@@ -344,7 +339,7 @@
- 4
+ 4
@@ -359,11 +354,16 @@
- 5
+ 5
- 8
+ 8
+
+
+
+
+ 7
diff --git a/python/tests/data/svg/tree.svg b/python/tests/data/svg/tree.svg
index f578dcb89c..242cec60b3 100644
--- a/python/tests/data/svg/tree.svg
+++ b/python/tests/data/svg/tree.svg
@@ -19,7 +19,7 @@
- 4
+ 4
@@ -34,7 +34,7 @@
- 5
+ 5
8
diff --git a/python/tests/data/svg/tree_both_axes.svg b/python/tests/data/svg/tree_both_axes.svg
index c0f14edcdf..c51a5e0bc8 100644
--- a/python/tests/data/svg/tree_both_axes.svg
+++ b/python/tests/data/svg/tree_both_axes.svg
@@ -73,7 +73,7 @@
- 4
+ 4
@@ -88,7 +88,7 @@
- 5
+ 5
8
diff --git a/python/tests/data/svg/tree_muts.svg b/python/tests/data/svg/tree_muts.svg
index f02ef51002..d5e0ecaaa7 100644
--- a/python/tests/data/svg/tree_muts.svg
+++ b/python/tests/data/svg/tree_muts.svg
@@ -19,7 +19,7 @@
- 4
+ 4
@@ -39,7 +39,7 @@
2
- 5
+ 5
@@ -53,7 +53,7 @@
1
- 9
+ 9
diff --git a/python/tests/data/svg/tree_muts_all_edge.svg b/python/tests/data/svg/tree_muts_all_edge.svg
index 5e69e262d2..368d06020d 100644
--- a/python/tests/data/svg/tree_muts_all_edge.svg
+++ b/python/tests/data/svg/tree_muts_all_edge.svg
@@ -81,7 +81,7 @@
4
- 4
+ 4
@@ -106,7 +106,7 @@
- 5
+ 5
@@ -115,7 +115,7 @@
5
- 7
+ 7
diff --git a/python/tests/data/svg/tree_simple_collapsed.svg b/python/tests/data/svg/tree_simple_collapsed.svg
new file mode 100644
index 0000000000..9299313dc3
--- /dev/null
+++ b/python/tests/data/svg/tree_simple_collapsed.svg
@@ -0,0 +1,48 @@
+
+
diff --git a/python/tests/data/svg/tree_subtree.svg b/python/tests/data/svg/tree_subtree.svg
new file mode 100644
index 0000000000..76a80e404c
--- /dev/null
+++ b/python/tests/data/svg/tree_subtree.svg
@@ -0,0 +1,44 @@
+
+
diff --git a/python/tests/data/svg/tree_subtrees_with_collapsed.svg b/python/tests/data/svg/tree_subtrees_with_collapsed.svg
new file mode 100644
index 0000000000..3effa996ea
--- /dev/null
+++ b/python/tests/data/svg/tree_subtrees_with_collapsed.svg
@@ -0,0 +1,110 @@
+
+
diff --git a/python/tests/data/svg/tree_timed_muts.svg b/python/tests/data/svg/tree_timed_muts.svg
index d5b356fff7..27e1c8aaee 100644
--- a/python/tests/data/svg/tree_timed_muts.svg
+++ b/python/tests/data/svg/tree_timed_muts.svg
@@ -19,7 +19,7 @@
- 4
+ 4
@@ -39,7 +39,7 @@
2
- 5
+ 5
@@ -53,7 +53,7 @@
1
- 9
+ 9
diff --git a/python/tests/data/svg/tree_x_axis.svg b/python/tests/data/svg/tree_x_axis.svg
index 66492b4d84..4a6cca8e30 100644
--- a/python/tests/data/svg/tree_x_axis.svg
+++ b/python/tests/data/svg/tree_x_axis.svg
@@ -72,7 +72,7 @@
4
- 4
+ 4
@@ -92,7 +92,7 @@
- 5
+ 5
@@ -101,7 +101,7 @@
5
- 7
+ 7
diff --git a/python/tests/data/svg/tree_y_axis_rank.svg b/python/tests/data/svg/tree_y_axis_rank.svg
index fc72850c02..9eb3b49d03 100644
--- a/python/tests/data/svg/tree_y_axis_rank.svg
+++ b/python/tests/data/svg/tree_y_axis_rank.svg
@@ -67,7 +67,7 @@
4
- 4
+ 4
@@ -87,7 +87,7 @@
- 5
+ 5
@@ -96,7 +96,7 @@
5
- 7
+ 7
diff --git a/python/tests/data/svg/ts.svg b/python/tests/data/svg/ts.svg
index 71ba72fd55..13bc8db1cb 100644
--- a/python/tests/data/svg/ts.svg
+++ b/python/tests/data/svg/ts.svg
@@ -113,7 +113,7 @@
- 4
+ 4
@@ -133,7 +133,7 @@
2
- 5
+ 5
@@ -147,7 +147,7 @@
1
- 9
+ 9
@@ -177,7 +177,7 @@
4
- 4
+ 4
@@ -197,7 +197,7 @@
- 5
+ 5
@@ -206,7 +206,7 @@
5
- 7
+ 7
@@ -226,7 +226,7 @@
- 4
+ 4
@@ -241,11 +241,11 @@
- 5
+ 5
- 6
+ 6
@@ -265,7 +265,7 @@
- 4
+ 4
@@ -285,11 +285,11 @@
- 5
+ 5
- 7
+ 7
@@ -309,7 +309,7 @@
- 4
+ 4
@@ -324,11 +324,11 @@
- 5
+ 5
- 8
+ 8
diff --git a/python/tests/data/svg/ts_max_trees.svg b/python/tests/data/svg/ts_max_trees.svg
index ad7ab7f392..c50331492d 100644
--- a/python/tests/data/svg/ts_max_trees.svg
+++ b/python/tests/data/svg/ts_max_trees.svg
@@ -157,11 +157,11 @@
- 7
+ 7
- 11
+ 11
@@ -182,11 +182,11 @@
- 8
+ 8
- 16
+ 16
33
@@ -215,11 +215,11 @@
- 7
+ 7
- 11
+ 11
@@ -240,11 +240,11 @@
- 8
+ 8
- 16
+ 16
25
@@ -273,11 +273,11 @@
- 7
+ 7
- 11
+ 11
@@ -303,11 +303,11 @@
1
- 8
+ 8
- 16
+ 16
30
@@ -357,15 +357,15 @@
- 6
+ 6
- 7
+ 7
- 10
+ 10
@@ -380,7 +380,7 @@
- 15
+ 15
42
@@ -415,11 +415,11 @@
- 6
+ 6
- 7
+ 7
@@ -428,7 +428,7 @@
9
- 10
+ 10
@@ -443,7 +443,7 @@
- 15
+ 15
39
diff --git a/python/tests/data/svg/ts_max_trees_treewise.svg b/python/tests/data/svg/ts_max_trees_treewise.svg
index 519d7f9ee4..055c1f3eaf 100644
--- a/python/tests/data/svg/ts_max_trees_treewise.svg
+++ b/python/tests/data/svg/ts_max_trees_treewise.svg
@@ -131,11 +131,11 @@
- 7
+ 7
- 11
+ 11
@@ -156,11 +156,11 @@
- 8
+ 8
- 16
+ 16
33
@@ -189,11 +189,11 @@
- 7
+ 7
- 11
+ 11
@@ -214,11 +214,11 @@
- 8
+ 8
- 16
+ 16
25
@@ -247,11 +247,11 @@
- 7
+ 7
- 11
+ 11
@@ -277,11 +277,11 @@
1
- 8
+ 8
- 16
+ 16
30
@@ -331,15 +331,15 @@
- 6
+ 6
- 7
+ 7
- 10
+ 10
@@ -354,7 +354,7 @@
- 15
+ 15
42
@@ -389,11 +389,11 @@
- 6
+ 6
- 7
+ 7
@@ -402,7 +402,7 @@
9
- 10
+ 10
@@ -417,7 +417,7 @@
- 15
+ 15
39
diff --git a/python/tests/data/svg/ts_multiroot.svg b/python/tests/data/svg/ts_multiroot.svg
index 207ca6bf1b..0d8dd1f35a 100644
--- a/python/tests/data/svg/ts_multiroot.svg
+++ b/python/tests/data/svg/ts_multiroot.svg
@@ -229,7 +229,7 @@
2
- 6
+ 6
@@ -244,7 +244,7 @@
- 11
+ 11
15
@@ -253,25 +253,6 @@
-
-
-
-
- 2
-
-
-
-
- 4
-
-
-
-
- 5
-
-
- 6
-
@@ -296,11 +277,30 @@
- 11
+ 11
12
+
+
+
+
+ 2
+
+
+
+
+ 4
+
+
+
+
+ 5
+
+
+ 6
+
@@ -333,7 +333,7 @@
- 6
+ 6
@@ -348,7 +348,7 @@
- 11
+ 11
14
@@ -380,7 +380,7 @@
- 6
+ 6
@@ -395,7 +395,7 @@
- 7
+ 7
14
@@ -404,20 +404,6 @@
-
-
-
-
- 1
-
-
-
-
- 3
-
-
- 7
-
@@ -453,20 +439,16 @@
- 6
+ 6
- 10
+ 10
13
-
-
-
-
-
+
@@ -480,6 +462,10 @@
7
+
+
+
+
@@ -494,6 +480,20 @@
13
+
+
+
+
+ 1
+
+
+
+
+ 3
+
+
+ 7
+
@@ -512,34 +512,6 @@
-
-
-
-
- 2
-
-
-
-
-
-
- 7
-
-
- 4
-
-
-
-
- 3
-
-
- 6
-
-
-
- 1
-
@@ -559,23 +531,23 @@
8
-
-
-
-
+
+
+ 1
+
-
+
-
-
-
- 9
-
2
-
+
+
+
+
+ 7
+
4
@@ -587,6 +559,10 @@
6
+
+
+
+
@@ -606,11 +582,35 @@
- 8
+ 8
9
+
+
+
+
+
+
+ 9
+
+
+ 2
+
+
+
+
+ 4
+
+
+
+
+ 3
+
+
+ 6
+
diff --git a/python/tests/data/svg/ts_mut_highlight.svg b/python/tests/data/svg/ts_mut_highlight.svg
index 836a8bd617..54935aa939 100644
--- a/python/tests/data/svg/ts_mut_highlight.svg
+++ b/python/tests/data/svg/ts_mut_highlight.svg
@@ -113,7 +113,7 @@
- 4
+ 4
@@ -133,7 +133,7 @@
2
- 5
+ 5
@@ -147,7 +147,7 @@
1
- 9
+ 9
@@ -177,7 +177,7 @@
4
- 4
+ 4
@@ -197,7 +197,7 @@
- 5
+ 5
@@ -206,7 +206,7 @@
5
- 7
+ 7
@@ -226,7 +226,7 @@
- 4
+ 4
@@ -241,11 +241,11 @@
- 5
+ 5
- 6
+ 6
@@ -265,7 +265,7 @@
- 4
+ 4
@@ -285,11 +285,11 @@
- 5
+ 5
- 7
+ 7
@@ -309,7 +309,7 @@
- 4
+ 4
@@ -324,11 +324,11 @@
- 5
+ 5
- 8
+ 8
diff --git a/python/tests/data/svg/ts_mut_times.svg b/python/tests/data/svg/ts_mut_times.svg
index 5dce0fff66..a72e9c7a9c 100644
--- a/python/tests/data/svg/ts_mut_times.svg
+++ b/python/tests/data/svg/ts_mut_times.svg
@@ -113,7 +113,7 @@
- 4
+ 4
@@ -133,7 +133,7 @@
2
- 5
+ 5
@@ -147,7 +147,7 @@
1
- 9
+ 9
@@ -177,7 +177,7 @@
4
- 4
+ 4
@@ -197,7 +197,7 @@
- 5
+ 5
@@ -206,7 +206,7 @@
5
- 7
+ 7
@@ -226,7 +226,7 @@
- 4
+ 4
@@ -241,11 +241,11 @@
- 5
+ 5
- 6
+ 6
@@ -265,7 +265,7 @@
- 4
+ 4
@@ -285,11 +285,11 @@
- 5
+ 5
- 7
+ 7
@@ -309,7 +309,7 @@
- 4
+ 4
@@ -324,11 +324,11 @@
- 5
+ 5
- 8
+ 8
diff --git a/python/tests/data/svg/ts_mut_times_logscale.svg b/python/tests/data/svg/ts_mut_times_logscale.svg
index f2e576324e..a269eab0c2 100644
--- a/python/tests/data/svg/ts_mut_times_logscale.svg
+++ b/python/tests/data/svg/ts_mut_times_logscale.svg
@@ -113,7 +113,7 @@
- 4
+ 4
@@ -133,7 +133,7 @@
2
- 5
+ 5
@@ -147,7 +147,7 @@
1
- 9
+ 9
@@ -177,7 +177,7 @@
4
- 4
+ 4
@@ -197,7 +197,7 @@
- 5
+ 5
@@ -206,7 +206,7 @@
5
- 7
+ 7
@@ -226,7 +226,7 @@
- 4
+ 4
@@ -241,11 +241,11 @@
- 5
+ 5
- 6
+ 6
@@ -265,7 +265,7 @@
- 4
+ 4
@@ -285,11 +285,11 @@
- 5
+ 5
- 7
+ 7
@@ -309,7 +309,7 @@
- 4
+ 4
@@ -324,11 +324,11 @@
- 5
+ 5
- 8
+ 8
diff --git a/python/tests/data/svg/ts_mut_times_titles.svg b/python/tests/data/svg/ts_mut_times_titles.svg
index f4ec6bc226..78aea0d3e1 100644
--- a/python/tests/data/svg/ts_mut_times_titles.svg
+++ b/python/tests/data/svg/ts_mut_times_titles.svg
@@ -135,7 +135,7 @@
NoDe4!
- 4
+ 4
@@ -163,7 +163,7 @@
NoDe5!
- 5
+ 5
@@ -183,7 +183,7 @@
NoDe9!
- 9
+ 9
@@ -223,7 +223,7 @@
NoDe4!
- 4
+ 4
@@ -251,7 +251,7 @@
NoDe5!
- 5
+ 5
@@ -264,7 +264,7 @@
NoDe7!
- 7
+ 7
@@ -290,7 +290,7 @@
NoDe4!
- 4
+ 4
@@ -311,13 +311,13 @@
NoDe5!
- 5
+ 5
NoDe6!
- 6
+ 6
@@ -343,7 +343,7 @@
NoDe4!
- 4
+ 4
@@ -371,13 +371,13 @@
NoDe5!
- 5
+ 5
NoDe7!
- 7
+ 7
@@ -403,7 +403,7 @@
NoDe4!
- 4
+ 4
@@ -424,13 +424,13 @@
NoDe5!
- 5
+ 5
NoDe8!
- 8
+ 8
diff --git a/python/tests/data/svg/ts_no_axes.svg b/python/tests/data/svg/ts_no_axes.svg
index 9897f1bcb8..5ce4804a63 100644
--- a/python/tests/data/svg/ts_no_axes.svg
+++ b/python/tests/data/svg/ts_no_axes.svg
@@ -21,7 +21,7 @@
- 4
+ 4
@@ -41,7 +41,7 @@
2
- 5
+ 5
@@ -55,7 +55,7 @@
1
- 9
+ 9
@@ -85,7 +85,7 @@
4
- 4
+ 4
@@ -105,7 +105,7 @@
- 5
+ 5
@@ -114,7 +114,7 @@
5
- 7
+ 7
@@ -134,7 +134,7 @@
- 4
+ 4
@@ -149,11 +149,11 @@
- 5
+ 5
- 6
+ 6
@@ -173,7 +173,7 @@
- 4
+ 4
@@ -193,11 +193,11 @@
- 5
+ 5
- 7
+ 7
@@ -217,7 +217,7 @@
- 4
+ 4
@@ -232,11 +232,11 @@
- 5
+ 5
- 8
+ 8
diff --git a/python/tests/data/svg/ts_plain.svg b/python/tests/data/svg/ts_plain.svg
index bedd75adf6..a5c01e3e0e 100644
--- a/python/tests/data/svg/ts_plain.svg
+++ b/python/tests/data/svg/ts_plain.svg
@@ -67,7 +67,7 @@
- 4
+ 4
@@ -87,7 +87,7 @@
2
- 5
+ 5
@@ -101,7 +101,7 @@
1
- 9
+ 9
@@ -131,7 +131,7 @@
4
- 4
+ 4
@@ -151,7 +151,7 @@
- 5
+ 5
@@ -160,7 +160,7 @@
5
- 7
+ 7
@@ -180,7 +180,7 @@
- 4
+ 4
@@ -195,11 +195,11 @@
- 5
+ 5
- 6
+ 6
@@ -219,7 +219,7 @@
- 4
+ 4
@@ -239,11 +239,11 @@
- 5
+ 5
- 7
+ 7
@@ -263,7 +263,7 @@
- 4
+ 4
@@ -278,11 +278,11 @@
- 5
+ 5
- 8
+ 8
diff --git a/python/tests/data/svg/ts_plain_no_xlab.svg b/python/tests/data/svg/ts_plain_no_xlab.svg
index 4a4cc4e348..ab4cc34cdf 100644
--- a/python/tests/data/svg/ts_plain_no_xlab.svg
+++ b/python/tests/data/svg/ts_plain_no_xlab.svg
@@ -64,7 +64,7 @@
- 4
+ 4
@@ -84,7 +84,7 @@
2
- 5
+ 5
@@ -98,7 +98,7 @@
1
- 9
+ 9
@@ -128,7 +128,7 @@
4
- 4
+ 4
@@ -148,7 +148,7 @@
- 5
+ 5
@@ -157,7 +157,7 @@
5
- 7
+ 7
@@ -177,7 +177,7 @@
- 4
+ 4
@@ -192,11 +192,11 @@
- 5
+ 5
- 6
+ 6
@@ -216,7 +216,7 @@
- 4
+ 4
@@ -236,11 +236,11 @@
- 5
+ 5
- 7
+ 7
@@ -260,7 +260,7 @@
- 4
+ 4
@@ -275,11 +275,11 @@
- 5
+ 5
- 8
+ 8
diff --git a/python/tests/data/svg/ts_plain_y.svg b/python/tests/data/svg/ts_plain_y.svg
index 9a63f2d30a..72f704f22b 100644
--- a/python/tests/data/svg/ts_plain_y.svg
+++ b/python/tests/data/svg/ts_plain_y.svg
@@ -96,7 +96,7 @@
- 4
+ 4
@@ -116,7 +116,7 @@
2
- 5
+ 5
@@ -130,7 +130,7 @@
1
- 9
+ 9
@@ -160,7 +160,7 @@
4
- 4
+ 4
@@ -180,7 +180,7 @@
- 5
+ 5
@@ -189,7 +189,7 @@
5
- 7
+ 7
@@ -209,7 +209,7 @@
- 4
+ 4
@@ -224,11 +224,11 @@
- 5
+ 5
- 6
+ 6
@@ -248,7 +248,7 @@
- 4
+ 4
@@ -268,11 +268,11 @@
- 5
+ 5
- 7
+ 7
@@ -292,7 +292,7 @@
- 4
+ 4
@@ -307,11 +307,11 @@
- 5
+ 5
- 8
+ 8
diff --git a/python/tests/data/svg/ts_rank.svg b/python/tests/data/svg/ts_rank.svg
index ac3c558d13..de9f9390e7 100644
--- a/python/tests/data/svg/ts_rank.svg
+++ b/python/tests/data/svg/ts_rank.svg
@@ -163,7 +163,7 @@
- 4
+ 4
@@ -183,7 +183,7 @@
2
- 5
+ 5
@@ -197,7 +197,7 @@
1
- 9
+ 9
@@ -227,7 +227,7 @@
4
- 4
+ 4
@@ -247,7 +247,7 @@
- 5
+ 5
@@ -256,7 +256,7 @@
5
- 7
+ 7
@@ -276,7 +276,7 @@
- 4
+ 4
@@ -291,11 +291,11 @@
- 5
+ 5
- 6
+ 6
@@ -315,7 +315,7 @@
- 4
+ 4
@@ -335,11 +335,11 @@
- 5
+ 5
- 7
+ 7
@@ -359,7 +359,7 @@
- 4
+ 4
@@ -374,11 +374,11 @@
- 5
+ 5
- 8
+ 8
diff --git a/python/tests/data/svg/ts_x_lim.svg b/python/tests/data/svg/ts_x_lim.svg
index 0bcdfc48df..dcc6c63032 100644
--- a/python/tests/data/svg/ts_x_lim.svg
+++ b/python/tests/data/svg/ts_x_lim.svg
@@ -69,7 +69,7 @@
- 4
+ 4
@@ -84,11 +84,11 @@
- 5
+ 5
- 9
+ 9
@@ -118,7 +118,7 @@
4
- 4
+ 4
@@ -138,7 +138,7 @@
- 5
+ 5
@@ -147,7 +147,7 @@
5
- 7
+ 7
@@ -167,7 +167,7 @@
- 4
+ 4
@@ -182,11 +182,11 @@
- 5
+ 5
- 6
+ 6
diff --git a/python/tests/data/svg/ts_xlabel.svg b/python/tests/data/svg/ts_xlabel.svg
index f83f3aeb0b..3bf4c97858 100644
--- a/python/tests/data/svg/ts_xlabel.svg
+++ b/python/tests/data/svg/ts_xlabel.svg
@@ -113,7 +113,7 @@
- 4
+ 4
@@ -133,7 +133,7 @@
2
- 5
+ 5
@@ -147,7 +147,7 @@
1
- 9
+ 9
@@ -177,7 +177,7 @@
4
- 4
+ 4
@@ -197,7 +197,7 @@
- 5
+ 5
@@ -206,7 +206,7 @@
5
- 7
+ 7
@@ -226,7 +226,7 @@
- 4
+ 4
@@ -241,11 +241,11 @@
- 5
+ 5
- 6
+ 6
@@ -265,7 +265,7 @@
- 4
+ 4
@@ -285,11 +285,11 @@
- 5
+ 5
- 7
+ 7
@@ -309,7 +309,7 @@
- 4
+ 4
@@ -324,11 +324,11 @@
- 5
+ 5
- 8
+ 8
diff --git a/python/tests/data/svg/ts_y_axis.svg b/python/tests/data/svg/ts_y_axis.svg
index ed0b57c2af..6e4cefac15 100644
--- a/python/tests/data/svg/ts_y_axis.svg
+++ b/python/tests/data/svg/ts_y_axis.svg
@@ -163,7 +163,7 @@
- 4
+ 4
@@ -183,7 +183,7 @@
2
- 5
+ 5
@@ -197,7 +197,7 @@
1
- 9
+ 9
@@ -227,7 +227,7 @@
4
- 4
+ 4
@@ -247,7 +247,7 @@
- 5
+ 5
@@ -256,7 +256,7 @@
5
- 7
+ 7
@@ -276,7 +276,7 @@
- 4
+ 4
@@ -291,11 +291,11 @@
- 5
+ 5
- 6
+ 6
@@ -315,7 +315,7 @@
- 4
+ 4
@@ -335,11 +335,11 @@
- 5
+ 5
- 7
+ 7
@@ -359,7 +359,7 @@
- 4
+ 4
@@ -374,11 +374,11 @@
- 5
+ 5
- 8
+ 8
diff --git a/python/tests/data/svg/ts_y_axis_log.svg b/python/tests/data/svg/ts_y_axis_log.svg
index 57a48517cd..9b60d4e6ae 100644
--- a/python/tests/data/svg/ts_y_axis_log.svg
+++ b/python/tests/data/svg/ts_y_axis_log.svg
@@ -163,7 +163,7 @@
- 4
+ 4
@@ -183,7 +183,7 @@
2
- 5
+ 5
@@ -197,7 +197,7 @@
1
- 9
+ 9
@@ -227,7 +227,7 @@
4
- 4
+ 4
@@ -247,7 +247,7 @@
- 5
+ 5
@@ -256,7 +256,7 @@
5
- 7
+ 7
@@ -276,7 +276,7 @@
- 4
+ 4
@@ -291,11 +291,11 @@
- 5
+ 5
- 6
+ 6
@@ -315,7 +315,7 @@
- 4
+ 4
@@ -335,11 +335,11 @@
- 5
+ 5
- 7
+ 7
@@ -359,7 +359,7 @@
- 4
+ 4
@@ -374,11 +374,11 @@
- 5
+ 5
- 8
+ 8
diff --git a/python/tests/data/svg/ts_y_axis_regular.svg b/python/tests/data/svg/ts_y_axis_regular.svg
index 95b1c9b160..d90bd53a7b 100644
--- a/python/tests/data/svg/ts_y_axis_regular.svg
+++ b/python/tests/data/svg/ts_y_axis_regular.svg
@@ -191,7 +191,7 @@
- 4
+ 4
@@ -211,7 +211,7 @@
2
- 5
+ 5
@@ -225,7 +225,7 @@
1
- 9
+ 9
@@ -255,7 +255,7 @@
4
- 4
+ 4
@@ -275,7 +275,7 @@
- 5
+ 5
@@ -284,7 +284,7 @@
5
- 7
+ 7
@@ -304,7 +304,7 @@
- 4
+ 4
@@ -319,11 +319,11 @@
- 5
+ 5
- 6
+ 6
@@ -343,7 +343,7 @@
- 4
+ 4
@@ -363,11 +363,11 @@
- 5
+ 5
- 7
+ 7
@@ -387,7 +387,7 @@
- 4
+ 4
@@ -402,11 +402,11 @@
- 5
+ 5
- 8
+ 8
diff --git a/python/tests/test_drawing.py b/python/tests/test_drawing.py
index 1b537f4a0c..9c46c7d8e1 100644
--- a/python/tests/test_drawing.py
+++ b/python/tests/test_drawing.py
@@ -336,19 +336,21 @@ def test_multiroot(self):
def test_left_child(self):
t = self.get_nonbinary_tree()
- left_child = drawing.get_left_child(t, "postorder")
+ left_child = drawing.get_left_child(t, t.postorder())
for u in t.nodes(order="postorder"):
if t.num_children(u) > 0:
assert left_child[u] == t.children(u)[0]
def test_null_node_left_child(self):
t = self.get_nonbinary_tree()
- left_child = drawing.get_left_child(t, "minlex_postorder")
+ arr = list(t.nodes(order="minlex_postorder"))
+ left_child = drawing.get_left_child(t, arr)
assert left_child[tskit.NULL] == tskit.NULL
def test_leaf_node_left_child(self):
t = self.get_nonbinary_tree()
- left_child = drawing.get_left_child(t, "minlex_postorder")
+ arr = list(t.nodes(order="minlex_postorder"))
+ left_child = drawing.get_left_child(t, arr)
for u in t.samples():
assert left_child[u] == tskit.NULL
@@ -1462,7 +1464,7 @@ class TestDrawSvgBase(TestTreeDraw, xmlunittest.XmlTestMixin):
Base class for testing the SVG tree drawing method
"""
- def verify_basic_svg(self, svg, width=200, height=200, num_trees=1):
+ def verify_basic_svg(self, svg, width=200, height=200, num_trees=1, has_root=True):
prefix = "{http://www.w3.org/2000/svg}"
root = xml.etree.ElementTree.fromstring(svg)
assert root.tag == prefix + "svg"
@@ -1498,7 +1500,11 @@ def verify_basic_svg(self, svg, width=200, height=200, num_trees=1):
for group in groups:
assert "class" in group.attrib
cls = group.attrib["class"]
- assert re.search(r"\broot\b", cls)
+ # if a subtree plot, the top of the displayed topology is not a local root
+ if has_root:
+ assert re.search(r"\broot\b", cls)
+ else:
+ assert not re.search(r"\broot\b", cls)
class TestDrawSvg(TestDrawSvgBase):
@@ -2554,6 +2560,15 @@ def test_bad_x_regions(self):
with pytest.raises(ValueError, match="Invalid coordinates"):
ts.draw_svg(x_regions={(1, 0): "bad"})
+ def test_bad_ts_order(self):
+ ts = msprime.sim_ancestry(1, sequence_length=100, random_seed=1)
+ with pytest.raises(ValueError, match="Unknown display order"):
+ ts.draw_svg(order=(ts.first().nodes(order="minlex_postorder")))
+
+ def test_good_tree_order(self):
+ ts = msprime.sim_ancestry(1, sequence_length=100, random_seed=1)
+ ts.first().draw_svg(order=(ts.first().nodes(order="minlex_postorder")))
+
class TestDrawKnownSvg(TestDrawSvgBase):
"""
@@ -2955,6 +2970,61 @@ def test_known_max_num_trees_treewise(self, overwrite_viz, draw_plotbox):
svg, "ts_max_trees_treewise.svg", overwrite_viz, width=200 * (max_trees + 1)
)
+ def test_known_svg_tree_collapsed(self, overwrite_viz, draw_plotbox):
+ tree = tskit.Tree.generate_balanced(8)
+ remove_nodes = set()
+ remove_nodes_below = {8, 13}
+ for u in remove_nodes_below:
+ subtree_nodes = set(tree.nodes(root=u)) - {u}
+ remove_nodes.update(subtree_nodes)
+ order = [
+ u for u in tree.nodes(order="minlex_postorder") if u not in remove_nodes
+ ]
+ svg = tree.draw_svg(order=order, debug_box=draw_plotbox)
+ assert svg.count("multi") == len(remove_nodes_below)
+ assert svg.count(">+2<") == 1 # One tip has 2 samples below it
+ assert svg.count(">+4<") == 1 # Another tip has 4 samples below it
+ for u in order:
+ assert f'n{u}"' in svg or f"n{u} " in svg
+ for u in remove_nodes:
+ assert f'n{u}"' not in svg and f"n{u} " not in svg
+ self.verify_known_svg(svg, "tree_simple_collapsed.svg", overwrite_viz)
+
+ def test_known_svg_tree_subtree(self, overwrite_viz, draw_plotbox):
+ tree = tskit.Tree.generate_balanced(8)
+ order = [u for u in tree.nodes(root=10, order="minlex_postorder")]
+ # The balanced tree has all descendants of nodes 10 with IDs < 10
+ assert np.all(np.array(order) <= 10)
+ svg = tree.draw_svg(order=order, debug_box=draw_plotbox)
+ for u in order:
+ assert f'n{u}"' in svg or f"n{u} " in svg
+ for u in set(tree.nodes()) - set(order):
+ assert f'n{u}"' not in svg and f"n{u} " not in svg
+ self.verify_known_svg(svg, "tree_subtree.svg", overwrite_viz, has_root=False)
+
+ def test_known_svg_tree_subtrees_with_collapsed(self, overwrite_viz, draw_plotbox):
+ # Two subtrees, one with a collapsed node below node 16
+ tree = tskit.Tree.generate_balanced(16)
+ roots = [22, 25]
+ order = []
+ remove_nodes_below = 16
+ remove_nodes = set(tree.nodes(root=remove_nodes_below)) - {remove_nodes_below}
+ for root in roots:
+ order += [
+ u
+ for u in tree.nodes(root=root, order="minlex_postorder")
+ if u not in remove_nodes
+ ]
+ svg = tree.draw_svg(order=order, debug_box=draw_plotbox)
+ assert svg.count("multi") == 1 # One tip representing multiple nodes
+ for u in order:
+ assert f'n{u}"' in svg or f"n{u} " in svg
+ for u in remove_nodes:
+ assert f'n{u}"' not in svg and f"n{u} " not in svg
+ self.verify_known_svg(
+ svg, "tree_subtrees_with_collapsed.svg", overwrite_viz, has_root=False
+ )
+
class TestRounding:
def test_rnd(self):
diff --git a/python/tskit/drawing.py b/python/tskit/drawing.py
index 558f913d20..b479cb5094 100644
--- a/python/tskit/drawing.py
+++ b/python/tskit/drawing.py
@@ -175,6 +175,10 @@ def check_order(order):
"minlex": "minlex_postorder",
"tree": "postorder",
}
+ # Silently accept a tree traversal order as a valid order, so we can
+ # call this check twice if necessary
+ if order in traversal_orders.values():
+ return order
if order not in traversal_orders:
raise ValueError(
f"Unknown display order '{order}'. "
@@ -1097,6 +1101,7 @@ def __init__(
**kwargs,
)
x_scale = check_x_scale(x_scale)
+ order = check_order(order)
if node_labels is None:
node_labels = {u: str(u) for u in range(ts.num_nodes)}
if force_root_branch is None:
@@ -1386,7 +1391,11 @@ def __init__(
**kwargs,
)
self.tree = tree
- self.traversal_order = check_order(order)
+ if order is None or isinstance(order, str):
+ # Can't use the Tree.postorder array as we need minlex
+ self.postorder_nodes = list(tree.nodes(order=check_order(order)))
+ else:
+ self.postorder_nodes = order
# Create some instance variables for later use in plotting
self.node_mutations = collections.defaultdict(list)
@@ -1686,12 +1695,21 @@ def assign_x_coordinates(self):
# Set up x positions for nodes
node_x_coord = {}
leaf_x = 0 # First leaf starts at x=1, to give some space between Y axis & leaf
- for u in self.tree.nodes(order=self.traversal_order):
- if self.tree.is_leaf(u):
+ prev = self.tree.virtual_root
+ for u in self.postorder_nodes:
+ is_tip = self.tree.parent(prev) != u
+ prev = u
+ if is_tip:
leaf_x += 1
node_x_coord[u] = leaf_x
else:
- child_coords = [node_x_coord[c] for c in self.tree.children(u)]
+ child_coords = [
+ node_x_coord[c] for c in self.tree.children(u) if c in node_x_coord
+ ]
+ if len(child_coords) == 0:
+ raise ValueError(
+ "Nodes must be passed to the drawing function in postorder"
+ )
if len(child_coords) == 1:
node_x_coord[u] = child_coords[0]
else:
@@ -1741,29 +1759,60 @@ def info_classes(self, focal_node_id):
classes.add(f"s{mutation.site+ self.offsets.site}")
return sorted(classes)
+ def text_transform(self, position, dy=0):
+ line_h = self.text_height
+ sym_sz = self.symbol_size
+ transforms = {
+ "below": f"translate(0 {rnd(line_h - sym_sz / 2 + dy)})",
+ "above": f"translate(0 {rnd(-(line_h - sym_sz / 2) + dy)})",
+ "above_left": f"translate({rnd(-sym_sz / 2)} {rnd(-line_h / 2 + dy)})",
+ "above_right": f"translate({rnd(sym_sz / 2)} {-rnd(line_h / 2 + dy)})",
+ "left": f"translate({-rnd(2 + sym_sz / 2)} {rnd(dy)})",
+ "right": f"translate({rnd(2 + sym_sz / 2)} {rnd(dy)})",
+ }
+ return transforms[position]
+
def draw_tree(self):
+ # Note: the displayed tree may not be the same as self.tree, e.g. if the nodes
+ # have been collapsed, or a subtree is being displayed. The node_x_coord
+ # dictionary keys gives the nodes of the displayed tree, in postorder.
+ NodeDrawInfo = collections.namedtuple("NodeDrawInfo", ["pos", "is_tip"])
dwg = self.drawing
- node_coords = {
- u: np.array([x, self.timescaling.transform(self.node_height[u])])
- for u, x in self.node_x_coord.items()
- }
tree = self.tree
- left_child = get_left_child(tree, self.traversal_order)
-
- # Iterate over nodes, adding groups to reflect the tree hierarchy
+ left_child = get_left_child(tree, self.postorder_nodes)
+ parent_array = tree.parent_array
+
+ node_info = {}
+ roots = [] # Roots of the displated tree
+ prev = tree.virtual_root
+ for u, x in self.node_x_coord.items(): # Node ids `u` returned in postorder
+ node_info[u] = NodeDrawInfo(
+ pos=np.array([x, self.timescaling.transform(self.node_height[u])]),
+ # Detect if this is a "tip" in the displayed tree, even if
+ # it is not a leaf in the original tree, by looking at the prev parent
+ is_tip=(parent_array[prev] != u),
+ )
+ prev = u
+ if parent_array[u] not in self.node_x_coord:
+ roots.append(u)
+ # Iterate over displayed nodes, adding groups to reflect the tree hierarchy
stack = []
- for u in tree.roots:
- x, y = node_coords[u]
+ for u in roots:
+ x, y = node_info[u].pos
grp = dwg.g(
class_=" ".join(self.info_classes(u)),
transform=f"translate({rnd(x)} {rnd(y)})",
)
stack.append((u, self.get_plotbox().add(grp)))
+
+ # Preorder traversal, so we can create nested groups
while len(stack) > 0:
u, curr_svg_group = stack.pop()
- pu = node_coords[u]
+ pu, is_tip = node_info[u]
for focal in tree.children(u):
- fx, fy = node_coords[focal] - pu
+ if focal not in node_info:
+ continue
+ fx, fy = node_info[focal].pos - pu
new_svg_group = curr_svg_group.add(
dwg.g(
class_=" ".join(self.info_classes(focal)),
@@ -1773,31 +1822,30 @@ def draw_tree(self):
stack.append((focal, new_svg_group))
o = (0, 0)
- v = tree.parent(u)
-
- # Add edge first => on layer underneath anything else
- if v != NULL:
+ v = parent_array[u]
+
+ # Add edge above node first => on layer underneath anything else
+ draw_edge_above_node = False
+ try:
+ dx, dy = node_info[v].pos - pu
+ draw_edge_above_node = True
+ except KeyError:
+ # Must be a root
+ root_branch_l = self.min_root_branch_plot_length
+ if root_branch_l > 0:
+ if len(self.node_mutations[u]) > 0:
+ mtop = self.timescaling.transform(
+ self.node_mutations[u][0].time
+ )
+ root_branch_l = max(root_branch_l, pu[1] - mtop)
+ dx, dy = 0, -root_branch_l
+ draw_edge_above_node = True
+ if draw_edge_above_node:
add_class(self.edge_attrs[u], "edge")
- dx, dy = node_coords[v] - pu
path = dwg.path(
[("M", o), ("V", rnd(dy)), ("H", rnd(dx))], **self.edge_attrs[u]
)
curr_svg_group.add(path)
- else:
- 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 on this branch
- root_branch_l = max(
- root_branch_l,
- pu[1] - self.timescaling.transform(mutation.time),
- )
- path = dwg.path(
- [("M", o), ("V", rnd(-root_branch_l)), ("H", 0)],
- **self.edge_attrs[u],
- )
- curr_svg_group.add(path)
# Add mutation symbols + labels
for mutation in self.node_mutations[u]:
@@ -1824,37 +1872,63 @@ def draw_tree(self):
if mutation_id in self.mutation_titles:
symbol.set_desc(title=self.mutation_titles[mutation_id])
# Labels
- if u == left_child[tree.parent(u)]:
+ if u == left_child[parent_array[u]]:
mut_label_class = "lft"
- transform = f"translate(-{rnd(2+self.symbol_size/2)} 0)"
+ transform = self.text_transform("left")
else:
mut_label_class = "rgt"
- transform = f"translate({rnd(2+self.symbol_size/2)} 0)"
+ transform = self.text_transform("right")
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]))
- # Add node symbol + label next (visually above the edge subtending this node)
- # Symbols
- if self.tree.is_sample(u):
+ # Add node symbol + label (visually above the edge subtending this node)
+ # -> symbols
+ if tree.is_sample(u):
symbol = curr_svg_group.add(dwg.rect(**self.node_attrs[u]))
else:
symbol = curr_svg_group.add(dwg.circle(**self.node_attrs[u]))
+ multi_samples = None
+ if (
+ is_tip and tree.num_samples(u) > 1
+ ): # Multi-sample tip => trapezium shape
+ multi_samples = tree.num_samples(u)
+ trapezium_attrs = self.node_attrs[u].copy()
+ # Remove the shape-styling attributes
+ for unwanted_attr in ("size", "insert", "center", "r"):
+ trapezium_attrs.pop(unwanted_attr, None)
+ trapezium_attrs["points"] = [ # add a trapezium shape below the symbol
+ (self.symbol_size / 2, 0),
+ (self.symbol_size, self.symbol_size),
+ (-self.symbol_size, self.symbol_size),
+ (-self.symbol_size / 2, 0),
+ ]
+ add_class(trapezium_attrs, "multi")
+ curr_svg_group.add(dwg.polygon(**trapezium_attrs))
if u in self.node_titles:
symbol.set_desc(title=self.node_titles[u])
- # Labels
+ # -> labels
node_lab_attr = self.node_label_attrs[u]
- if tree.is_leaf(u):
- 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})"
+ if is_tip and multi_samples is None:
+ node_lab_attr["transform"] = self.text_transform("below")
+ elif u in roots and self.min_root_branch_plot_length == 0:
+ node_lab_attr["transform"] = self.text_transform("above")
else:
+ if multi_samples is not None:
+ curr_svg_group.add(
+ dwg.text(
+ text=f"+{multi_samples}",
+ transform=self.text_transform("below", dy=1),
+ font_style="italic",
+ class_="lab summary",
+ )
+ )
if u == left_child[tree.parent(u)]:
add_class(node_lab_attr, "lft")
- node_lab_attr["transform"] = f"translate(-3 -{self.text_height/2})"
+ node_lab_attr["transform"] = self.text_transform("above_left")
else:
add_class(node_lab_attr, "rgt")
- node_lab_attr["transform"] = f"translate(3 -{self.text_height/2})"
+ node_lab_attr["transform"] = self.text_transform("above_right")
curr_svg_group.add(dwg.text(**node_lab_attr))
@@ -1980,14 +2054,14 @@ def find_neighbours(u, neighbour):
return left_neighbour[:-1]
-def get_left_child(tree, traversal_order):
+def get_left_child(tree, postorder_nodes):
"""
Returns the left-most child of each node in the tree according to the
- specified traversal order. If a node has no children or NULL is passed
- in, return NULL.
+ traversal order listed in postorder_nodes. If a node has no children or
+ NULL is passed in, return NULL.
"""
left_child = np.full(tree.tree_sequence.num_nodes + 1, NULL, dtype=int)
- for u in tree.nodes(order=traversal_order):
+ for u in postorder_nodes:
parent = tree.parent(u)
if parent != NULL and left_child[parent] == NULL:
left_child[parent] = u
diff --git a/python/tskit/trees.py b/python/tskit/trees.py
index f7273e0fcb..c28c590e6b 100644
--- a/python/tskit/trees.py
+++ b/python/tskit/trees.py
@@ -1949,7 +1949,7 @@ def draw_svg(
mutation_titles=mutation_titles,
root_svg_attributes=root_svg_attributes,
style=style,
- order=order,
+ order=order, # NB undocumented: Tree.draw_svg can also take an iterable here
force_root_branch=force_root_branch,
symbol_size=symbol_size,
x_axis=x_axis,