Turn the Sankey down the page and add lineage shortcuts
#4
by lvwerra HF Staff - opened
- README.md +1 -1
- atlas.css +6 -4
- atlas.js +12 -3
- data/common_names.json +6 -6
- refresh_common_names.py +5 -5
- taxonomy.py +72 -56
- tests/test_taxonomy.py +29 -16
README.md
CHANGED
|
@@ -17,7 +17,7 @@ Future features and open design decisions are tracked in the [feature backlog](F
|
|
| 17 |
|
| 18 |
## GenBank taxonomy
|
| 19 |
|
| 20 |
-
The landing page opens with an interactive **tree of eukaryotic life**, using NCBI's current GenBank assembly summary and taxonomy. It is drawn as a Sankey diagram: each group is a bar split into annotated (green) and not-annotated (gray) assemblies, and flows carry both parts from a group into its lineages. Counts and percentages appear beside the groups, with details on hover. Each group is labelled with its scientific name and, where one exists, a plain-English name underneath, so the Latin backbone of the tree (Opisthokonta, Eumetazoa, Ecdysozoa) stays readable without prior knowledge. The default **Genome Atlas** tab contains the tree and its lineage controls. A separate **Database** tab holds accession search, segment visualization, and downloads, using the full published-file snapshot. RefSeq exploration can be added beneath the tree. The two equal-width navigation options span the top of the page.
|
| 21 |
|
| 22 |
The viewer is restricted to **Eukaryota (NCBI taxid 2759) and its descendants**, including search, breadcrumbs, and parent navigation. The model predicts eukaryotic annotations; bacteria, archaea, and viruses are outside this display. The current eukaryotic snapshot contains **70,395 assemblies**, of which **17,561 (24.9%)** have published annotations. The underlying SQLite snapshot retains the full NCBI inventory for refresh/provenance, while the interface uses only the eukaryotic subtree and its denominators. Assemblies with unresolved taxonomy cannot be assigned to that subtree and are excluded.
|
| 23 |
|
|
|
|
| 17 |
|
| 18 |
## GenBank taxonomy
|
| 19 |
|
| 20 |
+
The landing page opens with an interactive **tree of eukaryotic life**, using NCBI's current GenBank assembly summary and taxonomy. It is drawn as a Sankey diagram running **down the page**: each group is a horizontal bar split into annotated (green) and not-annotated (gray) assemblies, and flows carry both parts from a group into its lineages on the level below. Expanding a group adds a level underneath rather than a column to the right, so a full lineage — eukaryotes down to humans is about thirty levels — is one ordinary page scroll instead of an endless sideways one. The chart itself only scrolls on screens too narrow to keep the labels legible. Shortcut buttons above the tree jump straight to a lineage: animals, mammals, humans, birds, ray-finned fish, insects, jellyfish, green plants, flowering plants and fungi. Counts and percentages appear beside the groups, with details on hover. Each group is labelled with its scientific name and, where one exists, a plain-English name underneath, so the Latin backbone of the tree (Opisthokonta, Eumetazoa, Ecdysozoa) stays readable without prior knowledge. The default **Genome Atlas** tab contains the tree and its lineage controls. A separate **Database** tab holds accession search, segment visualization, and downloads, using the full published-file snapshot. RefSeq exploration can be added beneath the tree. The two equal-width navigation options span the top of the page.
|
| 21 |
|
| 22 |
The viewer is restricted to **Eukaryota (NCBI taxid 2759) and its descendants**, including search, breadcrumbs, and parent navigation. The model predicts eukaryotic annotations; bacteria, archaea, and viruses are outside this display. The current eukaryotic snapshot contains **70,395 assemblies**, of which **17,561 (24.9%)** have published annotations. The underlying SQLite snapshot retains the full NCBI inventory for refresh/provenance, while the interface uses only the eukaryotic subtree and its denominators. Assemblies with unresolved taxonomy cannot be assigned to that subtree and are excluded.
|
| 23 |
|
atlas.css
CHANGED
|
@@ -21,7 +21,7 @@
|
|
| 21 |
.atlas-breadcrumb button { border:0; background:none; color:inherit; padding:2px 0; }
|
| 22 |
.atlas-breadcrumb button:hover { text-decoration:underline; }
|
| 23 |
.crumb-divider { opacity:.4; }
|
| 24 |
-
.tree-scroll {
|
| 25 |
.life-tree { display:block; width:100%; height:auto; overflow:visible; }
|
| 26 |
.flow { stroke:none; transition:opacity .15s; }
|
| 27 |
.flow-annotated { fill:#40916f; opacity:.42; }
|
|
@@ -40,7 +40,7 @@
|
|
| 40 |
.tree-icon { opacity:.62; transition:opacity .15s; }
|
| 41 |
.tree-node.is-selected .tree-icon { opacity:.85; }
|
| 42 |
.tree-node:not(.is-direct):hover .tree-icon { opacity:.9; }
|
| 43 |
-
.tree-label,.tree-english,.tree-count { paint-order:stroke; stroke:#f7f8f0; stroke-width:4px; stroke-linejoin:round; }
|
| 44 |
.tree-label { font:600 13px Arial,Helvetica,sans-serif; fill:#254735; transition:fill .15s; }
|
| 45 |
.tree-node.is-selected .tree-label { font-weight:700; fill:#173c30; }
|
| 46 |
.tree-english { font:italic 11.5px Georgia,'Times New Roman',serif; fill:#5d7a66; }
|
|
@@ -58,15 +58,17 @@
|
|
| 58 |
.atlas-tooltip[hidden] { display:none; }
|
| 59 |
.atlas button:focus-visible { outline:2px solid #327555; outline-offset:3px; }
|
| 60 |
.atlas-leaf-note { color:#647467; font-size:13px; text-align:center; margin:0 0 20px; }
|
| 61 |
-
.atlas-mobile-hint { display:none; }
|
| 62 |
@media(max-width:800px) {
|
| 63 |
.atlas { padding:22px 16px 16px; border-radius:18px; }
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
.atlas-header { align-items:flex-start; flex-direction:column; gap:18px; }
|
| 65 |
.atlas-summary-block { border-left:0; padding:0; min-width:0; }
|
| 66 |
.atlas-summary { flex-wrap:wrap; }
|
| 67 |
.atlas h1 { font-size:35px; }
|
| 68 |
.atlas-nav { flex-wrap:wrap; margin-top:18px; }
|
| 69 |
-
.atlas-mobile-hint { display:inline; }
|
| 70 |
.atlas-footer { gap:9px; }
|
| 71 |
}
|
| 72 |
@media(prefers-reduced-motion:reduce) { .atlas * { transition:none!important; } }
|
|
|
|
| 21 |
.atlas-breadcrumb button { border:0; background:none; color:inherit; padding:2px 0; }
|
| 22 |
.atlas-breadcrumb button:hover { text-decoration:underline; }
|
| 23 |
.crumb-divider { opacity:.4; }
|
| 24 |
+
.tree-scroll { margin:0 -5px; }
|
| 25 |
.life-tree { display:block; width:100%; height:auto; overflow:visible; }
|
| 26 |
.flow { stroke:none; transition:opacity .15s; }
|
| 27 |
.flow-annotated { fill:#40916f; opacity:.42; }
|
|
|
|
| 40 |
.tree-icon { opacity:.62; transition:opacity .15s; }
|
| 41 |
.tree-node.is-selected .tree-icon { opacity:.85; }
|
| 42 |
.tree-node:not(.is-direct):hover .tree-icon { opacity:.9; }
|
| 43 |
+
.tree-label,.tree-english,.tree-count { paint-order:stroke; stroke:#f7f8f0; stroke-width:4px; stroke-linejoin:round; text-anchor:middle; }
|
| 44 |
.tree-label { font:600 13px Arial,Helvetica,sans-serif; fill:#254735; transition:fill .15s; }
|
| 45 |
.tree-node.is-selected .tree-label { font-weight:700; fill:#173c30; }
|
| 46 |
.tree-english { font:italic 11.5px Georgia,'Times New Roman',serif; fill:#5d7a66; }
|
|
|
|
| 58 |
.atlas-tooltip[hidden] { display:none; }
|
| 59 |
.atlas button:focus-visible { outline:2px solid #327555; outline-offset:3px; }
|
| 60 |
.atlas-leaf-note { color:#647467; font-size:13px; text-align:center; margin:0 0 20px; }
|
|
|
|
| 61 |
@media(max-width:800px) {
|
| 62 |
.atlas { padding:22px 16px 16px; border-radius:18px; }
|
| 63 |
+
/* Levels fill the page width, so on a phone the only way to keep the labels
|
| 64 |
+
legible is to let the chart itself scroll sideways again. */
|
| 65 |
+
.tree-scroll { overflow-x:auto; overscroll-behavior-x:contain; }
|
| 66 |
+
.life-tree { min-width:720px; }
|
| 67 |
.atlas-header { align-items:flex-start; flex-direction:column; gap:18px; }
|
| 68 |
.atlas-summary-block { border-left:0; padding:0; min-width:0; }
|
| 69 |
.atlas-summary { flex-wrap:wrap; }
|
| 70 |
.atlas h1 { font-size:35px; }
|
| 71 |
.atlas-nav { flex-wrap:wrap; margin-top:18px; }
|
|
|
|
| 72 |
.atlas-footer { gap:9px; }
|
| 73 |
}
|
| 74 |
@media(prefers-reduced-motion:reduce) { .atlas * { transition:none!important; } }
|
atlas.js
CHANGED
|
@@ -26,12 +26,21 @@ element.addEventListener('pointerleave', () => {
|
|
| 26 |
const tooltip = element.querySelector('.atlas-tooltip');
|
| 27 |
if (tooltip) tooltip.hidden = true;
|
| 28 |
});
|
| 29 |
-
//
|
|
|
|
| 30 |
let shownRoute = element.querySelector('.life-tree')?.dataset.route;
|
| 31 |
new MutationObserver(() => {
|
| 32 |
const route = element.querySelector('.life-tree')?.dataset.route;
|
| 33 |
if (!route || route === shownRoute) return;
|
|
|
|
| 34 |
shownRoute = route;
|
| 35 |
-
|
| 36 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
}).observe(element, {childList: true, subtree: true, attributes: true, attributeFilter: ['data-route']});
|
|
|
|
| 26 |
const tooltip = element.querySelector('.atlas-tooltip');
|
| 27 |
if (tooltip) tooltip.hidden = true;
|
| 28 |
});
|
| 29 |
+
// Expanding adds a level below. The page scrolls, not the widget, so only nudge
|
| 30 |
+
// it when the new bottom of the tree has fallen past the viewport.
|
| 31 |
let shownRoute = element.querySelector('.life-tree')?.dataset.route;
|
| 32 |
new MutationObserver(() => {
|
| 33 |
const route = element.querySelector('.life-tree')?.dataset.route;
|
| 34 |
if (!route || route === shownRoute) return;
|
| 35 |
+
const grew = route.length > shownRoute.length;
|
| 36 |
shownRoute = route;
|
| 37 |
+
if (!grew) return;
|
| 38 |
+
requestAnimationFrame(() => {
|
| 39 |
+
const tree = element.querySelector('.life-tree');
|
| 40 |
+
if (!tree) return;
|
| 41 |
+
const bottom = tree.getBoundingClientRect().bottom;
|
| 42 |
+
if (bottom > window.innerHeight) {
|
| 43 |
+
window.scrollBy({top: Math.min(bottom - window.innerHeight + 24, 400), behavior: 'smooth'});
|
| 44 |
+
}
|
| 45 |
+
});
|
| 46 |
}).observe(element, {childList: true, subtree: true, attributes: true, attributeFilter: ['data-route']});
|
data/common_names.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
{
|
| 2 |
-
"created_at": "2026-10-
|
| 3 |
"sources": {
|
| 4 |
"ncbi_taxonomy": "https://ftp.ncbi.nlm.nih.gov/pub/taxonomy/taxdump.tar.gz",
|
| 5 |
"name_classes": [
|
|
@@ -7360,7 +7360,7 @@
|
|
| 7360 |
"313693": "citrus nematode",
|
| 7361 |
"314003": "bluebanded goby",
|
| 7362 |
"314082": "pram bug",
|
| 7363 |
-
"314145": "carnivores, bats
|
| 7364 |
"314146": "primates and rodents",
|
| 7365 |
"314147": "Rodents and rabbits",
|
| 7366 |
"314285": "unknown marine gamma proteobacterium NOR5",
|
|
@@ -7605,7 +7605,7 @@
|
|
| 7605 |
"358446": "deep-sea limpets",
|
| 7606 |
"358815": "black redstart",
|
| 7607 |
"358819": "Isabelline wheatear",
|
| 7608 |
-
"359160": "bamboo, rice
|
| 7609 |
"360508": "javelin grunt",
|
| 7610 |
"360967": "Great evening bat",
|
| 7611 |
"363190": "Gray's grenadier anchovy",
|
|
@@ -8647,7 +8647,7 @@
|
|
| 8647 |
"1324310": "pale-rose death cap",
|
| 8648 |
"1328047": "Battersby's green snake",
|
| 8649 |
"1328070": "greater bamboo lemur",
|
| 8650 |
-
"1329799": "turtles, birds
|
| 8651 |
"1329912": "lizards and snakes",
|
| 8652 |
"1329950": "lizards and snakes",
|
| 8653 |
"1329961": "split-tongued squamates",
|
|
@@ -8655,7 +8655,7 @@
|
|
| 8655 |
"1330544": "Old World anguimorph lizards",
|
| 8656 |
"1333648": "Mekong tiger perch",
|
| 8657 |
"1334976": "Pawak croaker",
|
| 8658 |
-
"1338369": "lungfish and
|
| 8659 |
"1342827": "Ethiopian burrowing tree frog",
|
| 8660 |
"1342833": "Erlanger's grass frog",
|
| 8661 |
"1342840": "Shoa forest treefrog",
|
|
@@ -9063,7 +9063,7 @@
|
|
| 9063 |
"2677969": "Greater spotted eagle",
|
| 9064 |
"2681549": "s__Sphingomonas_G profundi",
|
| 9065 |
"2686024": "amoeboid protists",
|
| 9066 |
-
"2687318": "single-celled animal
|
| 9067 |
"2692011": "many-banded treefrog",
|
| 9068 |
"2692248": "green algae",
|
| 9069 |
"2696291": "brown algae and diatoms",
|
|
|
|
| 1 |
{
|
| 2 |
+
"created_at": "2026-10-07T15:57:39.505254+00:00",
|
| 3 |
"sources": {
|
| 4 |
"ncbi_taxonomy": "https://ftp.ncbi.nlm.nih.gov/pub/taxonomy/taxdump.tar.gz",
|
| 5 |
"name_classes": [
|
|
|
|
| 7360 |
"313693": "citrus nematode",
|
| 7361 |
"314003": "bluebanded goby",
|
| 7362 |
"314082": "pram bug",
|
| 7363 |
+
"314145": "carnivores, bats, ungulates",
|
| 7364 |
"314146": "primates and rodents",
|
| 7365 |
"314147": "Rodents and rabbits",
|
| 7366 |
"314285": "unknown marine gamma proteobacterium NOR5",
|
|
|
|
| 7605 |
"358446": "deep-sea limpets",
|
| 7606 |
"358815": "black redstart",
|
| 7607 |
"358819": "Isabelline wheatear",
|
| 7608 |
+
"359160": "bamboo, rice, wheat grasses",
|
| 7609 |
"360508": "javelin grunt",
|
| 7610 |
"360967": "Great evening bat",
|
| 7611 |
"363190": "Gray's grenadier anchovy",
|
|
|
|
| 8647 |
"1324310": "pale-rose death cap",
|
| 8648 |
"1328047": "Battersby's green snake",
|
| 8649 |
"1328070": "greater bamboo lemur",
|
| 8650 |
+
"1329799": "turtles, birds, crocodiles",
|
| 8651 |
"1329912": "lizards and snakes",
|
| 8652 |
"1329950": "lizards and snakes",
|
| 8653 |
"1329961": "split-tongued squamates",
|
|
|
|
| 8655 |
"1330544": "Old World anguimorph lizards",
|
| 8656 |
"1333648": "Mekong tiger perch",
|
| 8657 |
"1334976": "Pawak croaker",
|
| 8658 |
+
"1338369": "lungfish and tetrapods",
|
| 8659 |
"1342827": "Ethiopian burrowing tree frog",
|
| 8660 |
"1342833": "Erlanger's grass frog",
|
| 8661 |
"1342840": "Shoa forest treefrog",
|
|
|
|
| 9063 |
"2677969": "Greater spotted eagle",
|
| 9064 |
"2681549": "s__Sphingomonas_G profundi",
|
| 9065 |
"2686024": "amoeboid protists",
|
| 9066 |
+
"2687318": "single-celled animal kin",
|
| 9067 |
"2692011": "many-banded treefrog",
|
| 9068 |
"2692248": "green algae",
|
| 9069 |
"2696291": "brown algae and diatoms",
|
refresh_common_names.py
CHANGED
|
@@ -43,7 +43,7 @@ CURATED = {
|
|
| 43 |
1035538: ("Mamiellophyceae", "tiny marine green algae"),
|
| 44 |
13792: ("Mamiellales", "tiny marine green algae"),
|
| 45 |
127916: ("Ichthyosporea", "parasites of fish and kin"),
|
| 46 |
-
2687318: ("Filasterea", "single-celled animal
|
| 47 |
2686024: ("Rotosphaerida", "amoeboid protists"),
|
| 48 |
6042: ("Demospongiae", "common sponges"),
|
| 49 |
1913637: ("Mucoromycota", "bread moulds and kin"),
|
|
@@ -96,7 +96,7 @@ CURATED = {
|
|
| 96 |
89593: ("Craniata", "animals with a skull"),
|
| 97 |
117570: ("Teleostomi", "jawed vertebrates with bone"),
|
| 98 |
8287: ("Sarcopterygii", "lobe-finned fish and kin"),
|
| 99 |
-
1338369: ("Dipnotetrapodomorpha", "lungfish and
|
| 100 |
186623: ("Actinopteri", "ray-finned fish"),
|
| 101 |
41665: ("Neopterygii", "modern ray-finned fish"),
|
| 102 |
1489341: ("Osteoglossocephalai", "bony-tongued fish and kin"),
|
|
@@ -116,7 +116,7 @@ CURATED = {
|
|
| 116 |
32519: ("Ostariophysi", "carps and catfish"),
|
| 117 |
186626: ("Otophysi", "carps and catfish"),
|
| 118 |
186627: ("Cypriniphysae", "carps and loaches"),
|
| 119 |
-
1329799: ("Archelosauria", "turtles, birds
|
| 120 |
8492: ("Archosauria", "birds and crocodiles"),
|
| 121 |
436489: ("Saurischia", "dinosaurs, birds today"),
|
| 122 |
436491: ("Theropoda", "dinosaurs, birds today"),
|
|
@@ -129,7 +129,7 @@ CURATED = {
|
|
| 129 |
32525: ("Theria", "live-bearing mammals"),
|
| 130 |
1437010: ("Boreoeutheria", "most placental mammals"),
|
| 131 |
314146: ("Euarchontoglires", "primates and rodents"),
|
| 132 |
-
314145: ("Laurasiatheria", "carnivores, bats
|
| 133 |
376913: ("Haplorrhini", "monkeys, apes and tarsiers"),
|
| 134 |
314293: ("Simiiformes", "monkeys and apes"),
|
| 135 |
9526: ("Catarrhini", "Old World monkeys and apes"),
|
|
@@ -196,7 +196,7 @@ CURATED = {
|
|
| 196 |
4734: ("commelinids", "grasses, palms and kin"),
|
| 197 |
38820: ("Poales", "grasses and sedges"),
|
| 198 |
147368: ("Pooideae", "temperate grasses"),
|
| 199 |
-
359160: ("BOP clade", "bamboo, rice
|
| 200 |
1648038: ("Triticodae", "wheat and barley grasses"),
|
| 201 |
91827: ("Gunneridae", "core eudicot plants"),
|
| 202 |
1437201: ("Pentapetalae", "core eudicot plants"),
|
|
|
|
| 43 |
1035538: ("Mamiellophyceae", "tiny marine green algae"),
|
| 44 |
13792: ("Mamiellales", "tiny marine green algae"),
|
| 45 |
127916: ("Ichthyosporea", "parasites of fish and kin"),
|
| 46 |
+
2687318: ("Filasterea", "single-celled animal kin"),
|
| 47 |
2686024: ("Rotosphaerida", "amoeboid protists"),
|
| 48 |
6042: ("Demospongiae", "common sponges"),
|
| 49 |
1913637: ("Mucoromycota", "bread moulds and kin"),
|
|
|
|
| 96 |
89593: ("Craniata", "animals with a skull"),
|
| 97 |
117570: ("Teleostomi", "jawed vertebrates with bone"),
|
| 98 |
8287: ("Sarcopterygii", "lobe-finned fish and kin"),
|
| 99 |
+
1338369: ("Dipnotetrapodomorpha", "lungfish and tetrapods"),
|
| 100 |
186623: ("Actinopteri", "ray-finned fish"),
|
| 101 |
41665: ("Neopterygii", "modern ray-finned fish"),
|
| 102 |
1489341: ("Osteoglossocephalai", "bony-tongued fish and kin"),
|
|
|
|
| 116 |
32519: ("Ostariophysi", "carps and catfish"),
|
| 117 |
186626: ("Otophysi", "carps and catfish"),
|
| 118 |
186627: ("Cypriniphysae", "carps and loaches"),
|
| 119 |
+
1329799: ("Archelosauria", "turtles, birds, crocodiles"),
|
| 120 |
8492: ("Archosauria", "birds and crocodiles"),
|
| 121 |
436489: ("Saurischia", "dinosaurs, birds today"),
|
| 122 |
436491: ("Theropoda", "dinosaurs, birds today"),
|
|
|
|
| 129 |
32525: ("Theria", "live-bearing mammals"),
|
| 130 |
1437010: ("Boreoeutheria", "most placental mammals"),
|
| 131 |
314146: ("Euarchontoglires", "primates and rodents"),
|
| 132 |
+
314145: ("Laurasiatheria", "carnivores, bats, ungulates"),
|
| 133 |
376913: ("Haplorrhini", "monkeys, apes and tarsiers"),
|
| 134 |
314293: ("Simiiformes", "monkeys and apes"),
|
| 135 |
9526: ("Catarrhini", "Old World monkeys and apes"),
|
|
|
|
| 196 |
4734: ("commelinids", "grasses, palms and kin"),
|
| 197 |
38820: ("Poales", "grasses and sedges"),
|
| 198 |
147368: ("Pooideae", "temperate grasses"),
|
| 199 |
+
359160: ("BOP clade", "bamboo, rice, wheat grasses"),
|
| 200 |
1648038: ("Triticodae", "wheat and barley grasses"),
|
| 201 |
91827: ("Gunneridae", "core eudicot plants"),
|
| 202 |
1437201: ("Pentapetalae", "core eudicot plants"),
|
taxonomy.py
CHANGED
|
@@ -28,8 +28,14 @@ EUK_TREE = """WITH RECURSIVE euk(taxid) AS (
|
|
| 28 |
) """
|
| 29 |
# Scientific names stay the primary label; English glosses are a secondary line.
|
| 30 |
# See refresh_common_names.py for how the lookup is built.
|
| 31 |
-
MAX_ENGLISH =
|
| 32 |
-
MAX_LABEL =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
|
| 35 |
COLUMN_LIMIT = 6
|
|
@@ -181,36 +187,46 @@ class Taxonomy:
|
|
| 181 |
if depth < len(steps) and not any(item['selected'] for item in nodes):
|
| 182 |
raise ValueError("Choose a eukaryotic taxon from this snapshot.")
|
| 183 |
lineage = [taxon(t) for t, _ in steps]
|
| 184 |
-
shortcuts = {t: encode_path((r['taxid'], 0) for r in self.lineage(conn, t)) for t
|
| 185 |
return self.render(columns, steps, lineage, shortcuts, has_children=bool(tail))
|
| 186 |
|
| 187 |
def render(self, columns, steps, lineage, shortcuts, has_children):
|
| 188 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
# Silhouettes are not square: they run from tall and thin to long and
|
| 190 |
# flat. Fitting them all into one box turns most into slivers, so each
|
| 191 |
# gets its own box with the same area and its own proportions.
|
| 192 |
-
icon_area, icon_max,
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
|
|
|
| 199 |
fixed, scale = set(), 0
|
| 200 |
for _ in range(len(nodes) + 1):
|
| 201 |
-
free =
|
| 202 |
flexible = sum(n['total'] for k, n in enumerate(nodes) if k not in fixed)
|
| 203 |
scale = free / flexible if flexible else 0
|
| 204 |
small = fixed | {k for k, n in enumerate(nodes) if n['total'] * scale < slot}
|
| 205 |
if small == fixed:
|
| 206 |
break
|
| 207 |
fixed = small
|
| 208 |
-
|
| 209 |
for k, n in enumerate(nodes):
|
| 210 |
room = slot if k in fixed else n['total'] * scale
|
| 211 |
-
n['
|
| 212 |
-
n['
|
| 213 |
-
|
|
|
|
| 214 |
|
| 215 |
def clip(text, limit):
|
| 216 |
return text if len(text) <= limit else text[:limit - 2] + "…"
|
|
@@ -218,35 +234,35 @@ class Taxonomy:
|
|
| 218 |
def share(n):
|
| 219 |
return n['covered'] / n['total'] if self.coverage and n['total'] else 0
|
| 220 |
|
| 221 |
-
def ribbon(
|
| 222 |
-
bend = (
|
| 223 |
-
return (f'<path d="M{
|
| 224 |
-
f'L{
|
| 225 |
|
| 226 |
flows, nodes = [], []
|
| 227 |
for depth, column in enumerate(columns):
|
| 228 |
-
|
| 229 |
-
|
| 230 |
for n in column:
|
| 231 |
-
n['
|
| 232 |
for depth, column in enumerate(columns[:-1]):
|
| 233 |
parent = next((n for n in column if n['selected']), None)
|
| 234 |
children = columns[depth + 1]
|
| 235 |
if not parent:
|
| 236 |
continue
|
| 237 |
total = sum(c['total'] for c in children) or 1
|
| 238 |
-
cursor = parent['
|
| 239 |
for child in children:
|
| 240 |
a0 = cursor
|
| 241 |
-
a1 = cursor + parent['
|
| 242 |
cursor = a1
|
| 243 |
f = share(child)
|
| 244 |
-
am, bm = a0 + f * (a1 - a0), child['
|
| 245 |
-
|
| 246 |
if f > 0:
|
| 247 |
-
flows.append(ribbon(
|
| 248 |
if f < 1:
|
| 249 |
-
flows.append(ribbon(
|
| 250 |
|
| 251 |
for depth, column in enumerate(columns):
|
| 252 |
on_path = depth < len(steps)
|
|
@@ -254,7 +270,7 @@ class Taxonomy:
|
|
| 254 |
for n in column:
|
| 255 |
if n['borrowed_icon'] and shown[n['icon']] > 1:
|
| 256 |
n['icon'] = "" # identical borrowed outlines read as "same group"
|
| 257 |
-
x, y,
|
| 258 |
f = share(n)
|
| 259 |
percent = 100 * f
|
| 260 |
title = f"{n['name']} ({n['english']})" if n['english'] else n['name']
|
|
@@ -263,39 +279,39 @@ class Taxonomy:
|
|
| 263 |
if n.get('aggregate'):
|
| 264 |
detail += " Display aggregate, not a taxonomic clade. Activate to see its lineages."
|
| 265 |
elif not n.get('direct') and not n['selected']:
|
| 266 |
-
detail += " Activate to
|
| 267 |
action = "" if n.get('direct') else f'data-path="{n["path"]}" role="button" tabindex="0"'
|
| 268 |
classes = "tree-node" + (" is-selected" if n['selected'] else " is-sibling" if on_path else "") + \
|
| 269 |
(" is-direct" if n.get('direct') else "")
|
| 270 |
nodes.append(f'<g class="{classes}" {action} aria-label="{escape(detail, quote=True)}" data-detail="{escape(detail, quote=True)}">')
|
| 271 |
-
|
| 272 |
-
nodes.append(f'<rect x="{
|
| 273 |
-
|
|
|
|
| 274 |
if green > 0:
|
| 275 |
-
nodes.append(f'<rect x="{x}" y="{y
|
| 276 |
-
if green <
|
| 277 |
-
nodes.append(f'<rect x="{x
|
| 278 |
-
nodes.append(f'<rect x="{x}" y="{y
|
| 279 |
label, english = clip(n['label'], MAX_LABEL), clip(n['english'], MAX_ENGLISH)
|
| 280 |
stats = f"{percent:.1f}% · {n['covered']:,} / {n['total']:,}" if self.coverage else f"{n['total']:,} assemblies"
|
| 281 |
-
# The
|
| 282 |
-
# labels
|
| 283 |
if n['icon']:
|
| 284 |
ratio = self.icon_credits.get(n['icon'], {}).get('ratio') or 1.0
|
| 285 |
icon_h = min(icon_max, max(6, math.sqrt(icon_area / ratio)))
|
| 286 |
icon_w = min(icon_max, max(6, icon_h * ratio))
|
| 287 |
icon_h = min(icon_max, max(6, icon_w / ratio))
|
| 288 |
-
nodes.append(f'<image class="tree-icon" x="{
|
|
|
|
| 289 |
f'width="{icon_w:.1f}" height="{icon_h:.1f}" '
|
| 290 |
f'href="{ICON_ROUTE}{ICON_DIR / (n["icon"] + ".svg")}"/>')
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
head, stat_line = (cy - 10, cy + 16) if english else (cy - 3, cy + 13)
|
| 294 |
-
nodes.append(f'<text x="{text_x}" y="{head:.1f}" class="tree-label">{escape(label)}</text>')
|
| 295 |
if english:
|
| 296 |
-
nodes.append(f'<text x="{
|
| 297 |
-
nodes.append(f'<text x="{
|
| 298 |
-
svg = (f'<svg viewBox="0 0 {width} {height}" class="life-tree" data-route="{encode_path(steps)}"
|
| 299 |
f'aria-label="Eukaryotic taxonomy and annotation coverage">' + ''.join(flows) + ''.join(nodes) + '</svg>')
|
| 300 |
|
| 301 |
focus = next(n for n in columns[len(steps) - 1] if n['selected'])
|
|
@@ -320,6 +336,7 @@ class Taxonomy:
|
|
| 320 |
else:
|
| 321 |
summary = f'<div class="atlas-kicker">{escape(selected_name)}</div><div class="atlas-summary"><strong>{total:,}</strong><span>GenBank assemblies<br>Coverage unavailable</span></div>'
|
| 322 |
legend = '<div class="atlas-legend"><i class="atlas-swatch atlas-missing"></i><span>Assemblies · coverage unavailable</span></div>'
|
|
|
|
| 323 |
leaf_note = '<p class="atlas-leaf-note">This is a terminal taxon in the assembly snapshot. Use the breadcrumb to explore its relatives.</p>' if not has_children else ''
|
| 324 |
return f'''<section class="atlas" aria-label="Tree of eukaryotic life">
|
| 325 |
<header class="atlas-header">
|
|
@@ -329,18 +346,16 @@ class Taxonomy:
|
|
| 329 |
<div class="atlas-summary-block">{summary}</div>
|
| 330 |
</header>
|
| 331 |
<nav class="atlas-nav" aria-label="Explore major lineages">
|
| 332 |
-
<div class="atlas-shortcuts"><button data-path="{DEFAULT_PATH}">All eukaryotes</button>
|
| 333 |
-
<button data-path="{shortcuts[33208]}">Animals</button><button data-path="{shortcuts[33090]}">Green plants</button>
|
| 334 |
-
<button data-path="{shortcuts[4751]}">Fungi</button></div>
|
| 335 |
<button class="atlas-back" data-path="{parent}">← Collapse last group</button>
|
| 336 |
</nav>
|
| 337 |
<div class="atlas-breadcrumb">{breadcrumbs}</div>
|
| 338 |
<div class="tree-scroll" tabindex="0" aria-label="Interactive tree. Scroll horizontally to see deeper lineages.">{svg}</div>
|
| 339 |
{leaf_note}<div class="atlas-tooltip" role="tooltip" hidden></div>
|
| 340 |
<footer class="atlas-footer">{legend}
|
| 341 |
-
<span>Click a group to
|
| 342 |
<span class="atlas-date">Updated {stamp}</span></footer>
|
| 343 |
-
<div class="atlas-footnote">Bar and flow
|
| 344 |
</section>'''
|
| 345 |
|
| 346 |
|
|
@@ -408,9 +423,10 @@ def build_taxonomy_tab():
|
|
| 408 |
with gr.Accordion("About the tree and coverage", open=False, elem_classes="atlas-disclosure"):
|
| 409 |
gr.Markdown("This tree follows **NCBI Taxonomy within Eukaryota**. Animals and fungi share the Opisthokonta branch; "
|
| 410 |
"green plants include green algae. ‘Other lineages’ combines the remaining siblings for display and can be expanded. "
|
| 411 |
-
"Each expanded group adds a
|
| 412 |
-
"The opening view also expands the animal/fungal branch. Bars and flows
|
| 413 |
-
"
|
|
|
|
| 414 |
"Each group shows its scientific name with a plain-English name underneath where one is available. These come from "
|
| 415 |
"NCBI Taxonomy's common names plus a curated set of short glosses for the large unranked clades NCBI leaves unnamed, "
|
| 416 |
"such as Opisthokonta (animals and fungi) or Ecdysozoa (moulting animals). The glosses describe the living members of a "
|
|
|
|
| 28 |
) """
|
| 29 |
# Scientific names stay the primary label; English glosses are a secondary line.
|
| 30 |
# See refresh_common_names.py for how the lookup is built.
|
| 31 |
+
MAX_ENGLISH = 28
|
| 32 |
+
MAX_LABEL = 22
|
| 33 |
+
# Jumping-off points above the tree. Each one expands its whole lineage, so a
|
| 34 |
+
# deep pick like humans is also the quickest way to see what a full path looks
|
| 35 |
+
# like. Order runs broad to narrow.
|
| 36 |
+
SHORTCUTS = ((33208, "Animals"), (40674, "Mammals"), (9606, "Humans"), (8782, "Birds"),
|
| 37 |
+
(7898, "Ray-finned fish"), (50557, "Insects"), (6142, "Jellyfish"),
|
| 38 |
+
(33090, "Green plants"), (3398, "Flowering plants"), (4751, "Fungi"))
|
| 39 |
|
| 40 |
|
| 41 |
COLUMN_LIMIT = 6
|
|
|
|
| 187 |
if depth < len(steps) and not any(item['selected'] for item in nodes):
|
| 188 |
raise ValueError("Choose a eukaryotic taxon from this snapshot.")
|
| 189 |
lineage = [taxon(t) for t, _ in steps]
|
| 190 |
+
shortcuts = {t: encode_path((r['taxid'], 0) for r in self.lineage(conn, t)) for t, _ in SHORTCUTS}
|
| 191 |
return self.render(columns, steps, lineage, shortcuts, has_children=bool(tail))
|
| 192 |
|
| 193 |
def render(self, columns, steps, lineage, shortcuts, has_children):
|
| 194 |
+
"""Levels run down the page, so a whole lineage is one page scroll.
|
| 195 |
+
|
| 196 |
+
Each level is a horizontal band: bar widths follow assembly counts and
|
| 197 |
+
the label sits beneath its bar. Depth now costs height, which the page
|
| 198 |
+
can always give, rather than width, which it cannot.
|
| 199 |
+
"""
|
| 200 |
+
left, span, bar, top = 16, 1180, 15, 18
|
| 201 |
+
rung = 172 # distance from one level's bar to the next
|
| 202 |
+
label_gap, line = 11, 15
|
| 203 |
# Silhouettes are not square: they run from tall and thin to long and
|
| 204 |
# flat. Fitting them all into one box turns most into slivers, so each
|
| 205 |
# gets its own box with the same area and its own proportions.
|
| 206 |
+
icon_area, icon_max, icon_band = 24 * 24, 42, 44
|
| 207 |
+
block = bar + icon_band + 3 * line # a level's bar plus the caption under it
|
| 208 |
+
width = left * 2 + span
|
| 209 |
+
height = top + (len(columns) - 1) * rung + block + 14
|
| 210 |
+
|
| 211 |
+
def spread(nodes):
|
| 212 |
+
"""Bar widths follow assembly counts, with room under each for its label."""
|
| 213 |
+
gap, slot = (14 if len(nodes) > 1 else 0), 148
|
| 214 |
fixed, scale = set(), 0
|
| 215 |
for _ in range(len(nodes) + 1):
|
| 216 |
+
free = span - gap * (len(nodes) - 1) - slot * len(fixed)
|
| 217 |
flexible = sum(n['total'] for k, n in enumerate(nodes) if k not in fixed)
|
| 218 |
scale = free / flexible if flexible else 0
|
| 219 |
small = fixed | {k for k, n in enumerate(nodes) if n['total'] * scale < slot}
|
| 220 |
if small == fixed:
|
| 221 |
break
|
| 222 |
fixed = small
|
| 223 |
+
x = left
|
| 224 |
for k, n in enumerate(nodes):
|
| 225 |
room = slot if k in fixed else n['total'] * scale
|
| 226 |
+
n['w'] = max(3, n['total'] * scale)
|
| 227 |
+
n['x'] = x + (room - n['w']) / 2
|
| 228 |
+
n['slot'], n['slot_x'] = room, x
|
| 229 |
+
x += room + gap
|
| 230 |
|
| 231 |
def clip(text, limit):
|
| 232 |
return text if len(text) <= limit else text[:limit - 2] + "…"
|
|
|
|
| 234 |
def share(n):
|
| 235 |
return n['covered'] / n['total'] if self.coverage and n['total'] else 0
|
| 236 |
|
| 237 |
+
def ribbon(y0, a0, a1, y1, b0, b1, kind):
|
| 238 |
+
bend = (y0 + y1) / 2
|
| 239 |
+
return (f'<path d="M{a0:.1f},{y0} C{a0:.1f},{bend} {b0:.1f},{bend} {b0:.1f},{y1} '
|
| 240 |
+
f'L{b1:.1f},{y1} C{b1:.1f},{bend} {a1:.1f},{bend} {a1:.1f},{y0} Z" class="flow flow-{kind}"/>')
|
| 241 |
|
| 242 |
flows, nodes = [], []
|
| 243 |
for depth, column in enumerate(columns):
|
| 244 |
+
spread(column)
|
| 245 |
+
y = top + depth * rung
|
| 246 |
for n in column:
|
| 247 |
+
n['y'] = y
|
| 248 |
for depth, column in enumerate(columns[:-1]):
|
| 249 |
parent = next((n for n in column if n['selected']), None)
|
| 250 |
children = columns[depth + 1]
|
| 251 |
if not parent:
|
| 252 |
continue
|
| 253 |
total = sum(c['total'] for c in children) or 1
|
| 254 |
+
cursor = parent['x']
|
| 255 |
for child in children:
|
| 256 |
a0 = cursor
|
| 257 |
+
a1 = cursor + parent['w'] * child['total'] / total
|
| 258 |
cursor = a1
|
| 259 |
f = share(child)
|
| 260 |
+
am, bm = a0 + f * (a1 - a0), child['x'] + f * child['w']
|
| 261 |
+
y0, y1 = parent['y'] + block, child['y']
|
| 262 |
if f > 0:
|
| 263 |
+
flows.append(ribbon(y0, a0, am, y1, child['x'], bm, "annotated"))
|
| 264 |
if f < 1:
|
| 265 |
+
flows.append(ribbon(y0, am, a1, y1, bm, child['x'] + child['w'], "missing"))
|
| 266 |
|
| 267 |
for depth, column in enumerate(columns):
|
| 268 |
on_path = depth < len(steps)
|
|
|
|
| 270 |
for n in column:
|
| 271 |
if n['borrowed_icon'] and shown[n['icon']] > 1:
|
| 272 |
n['icon'] = "" # identical borrowed outlines read as "same group"
|
| 273 |
+
x, y, w = n['x'], n['y'], n['w']
|
| 274 |
f = share(n)
|
| 275 |
percent = 100 * f
|
| 276 |
title = f"{n['name']} ({n['english']})" if n['english'] else n['name']
|
|
|
|
| 279 |
if n.get('aggregate'):
|
| 280 |
detail += " Display aggregate, not a taxonomic clade. Activate to see its lineages."
|
| 281 |
elif not n.get('direct') and not n['selected']:
|
| 282 |
+
detail += " Activate to open this group below."
|
| 283 |
action = "" if n.get('direct') else f'data-path="{n["path"]}" role="button" tabindex="0"'
|
| 284 |
classes = "tree-node" + (" is-selected" if n['selected'] else " is-sibling" if on_path else "") + \
|
| 285 |
(" is-direct" if n.get('direct') else "")
|
| 286 |
nodes.append(f'<g class="{classes}" {action} aria-label="{escape(detail, quote=True)}" data-detail="{escape(detail, quote=True)}">')
|
| 287 |
+
centre = n['slot_x'] + n['slot'] / 2
|
| 288 |
+
nodes.append(f'<rect x="{n["slot_x"] - 5:.1f}" y="{y - 6}" width="{n["slot"] + 10:.1f}" '
|
| 289 |
+
f'height="{block:.0f}" rx="12" class="tree-hit"/>')
|
| 290 |
+
green = w * f
|
| 291 |
if green > 0:
|
| 292 |
+
nodes.append(f'<rect x="{x:.1f}" y="{y}" width="{green:.1f}" height="{bar}" class="bar bar-annotated"/>')
|
| 293 |
+
if green < w:
|
| 294 |
+
nodes.append(f'<rect x="{x + green:.1f}" y="{y}" width="{w - green:.1f}" height="{bar}" class="bar bar-missing"/>')
|
| 295 |
+
nodes.append(f'<rect x="{x:.1f}" y="{y}" width="{w:.1f}" height="{bar}" class="bar-outline"/>')
|
| 296 |
label, english = clip(n['label'], MAX_LABEL), clip(n['english'], MAX_ENGLISH)
|
| 297 |
stats = f"{percent:.1f}% · {n['covered']:,} / {n['total']:,}" if self.coverage else f"{n['total']:,} assemblies"
|
| 298 |
+
# The silhouette band is reserved whether or not this node has one,
|
| 299 |
+
# so labels sit on the same baseline right across a level.
|
| 300 |
if n['icon']:
|
| 301 |
ratio = self.icon_credits.get(n['icon'], {}).get('ratio') or 1.0
|
| 302 |
icon_h = min(icon_max, max(6, math.sqrt(icon_area / ratio)))
|
| 303 |
icon_w = min(icon_max, max(6, icon_h * ratio))
|
| 304 |
icon_h = min(icon_max, max(6, icon_w / ratio))
|
| 305 |
+
nodes.append(f'<image class="tree-icon" x="{centre - icon_w / 2:.1f}" '
|
| 306 |
+
f'y="{y + bar + label_gap + (icon_band - label_gap - icon_h) / 2:.1f}" '
|
| 307 |
f'width="{icon_w:.1f}" height="{icon_h:.1f}" '
|
| 308 |
f'href="{ICON_ROUTE}{ICON_DIR / (n["icon"] + ".svg")}"/>')
|
| 309 |
+
text_y = y + bar + icon_band + line
|
| 310 |
+
nodes.append(f'<text x="{centre:.1f}" y="{text_y:.0f}" class="tree-label">{escape(label)}</text>')
|
|
|
|
|
|
|
| 311 |
if english:
|
| 312 |
+
nodes.append(f'<text x="{centre:.1f}" y="{text_y + line:.0f}" class="tree-english">{escape(english)}</text>')
|
| 313 |
+
nodes.append(f'<text x="{centre:.1f}" y="{text_y + (2 if english else 1) * line:.0f}" class="tree-count">{stats}</text></g>')
|
| 314 |
+
svg = (f'<svg viewBox="0 0 {width} {height}" class="life-tree" data-route="{encode_path(steps)}" '
|
| 315 |
f'aria-label="Eukaryotic taxonomy and annotation coverage">' + ''.join(flows) + ''.join(nodes) + '</svg>')
|
| 316 |
|
| 317 |
focus = next(n for n in columns[len(steps) - 1] if n['selected'])
|
|
|
|
| 336 |
else:
|
| 337 |
summary = f'<div class="atlas-kicker">{escape(selected_name)}</div><div class="atlas-summary"><strong>{total:,}</strong><span>GenBank assemblies<br>Coverage unavailable</span></div>'
|
| 338 |
legend = '<div class="atlas-legend"><i class="atlas-swatch atlas-missing"></i><span>Assemblies · coverage unavailable</span></div>'
|
| 339 |
+
jumps = ''.join(f'<button data-path="{shortcuts[t]}">{escape(name)}</button>' for t, name in SHORTCUTS)
|
| 340 |
leaf_note = '<p class="atlas-leaf-note">This is a terminal taxon in the assembly snapshot. Use the breadcrumb to explore its relatives.</p>' if not has_children else ''
|
| 341 |
return f'''<section class="atlas" aria-label="Tree of eukaryotic life">
|
| 342 |
<header class="atlas-header">
|
|
|
|
| 346 |
<div class="atlas-summary-block">{summary}</div>
|
| 347 |
</header>
|
| 348 |
<nav class="atlas-nav" aria-label="Explore major lineages">
|
| 349 |
+
<div class="atlas-shortcuts"><button data-path="{DEFAULT_PATH}">All eukaryotes</button>{jumps}</div>
|
|
|
|
|
|
|
| 350 |
<button class="atlas-back" data-path="{parent}">← Collapse last group</button>
|
| 351 |
</nav>
|
| 352 |
<div class="atlas-breadcrumb">{breadcrumbs}</div>
|
| 353 |
<div class="tree-scroll" tabindex="0" aria-label="Interactive tree. Scroll horizontally to see deeper lineages.">{svg}</div>
|
| 354 |
{leaf_note}<div class="atlas-tooltip" role="tooltip" hidden></div>
|
| 355 |
<footer class="atlas-footer">{legend}
|
| 356 |
+
<span>Click a group to open it below</span>
|
| 357 |
<span class="atlas-date">Updated {stamp}</span></footer>
|
| 358 |
+
<div class="atlas-footnote">Bar and flow widths are proportional to assembly counts within each level; each level is rescaled to fill the width, so compare sizes within a level. Coverage counts assemblies with published annotations, including partial assemblies.</div>
|
| 359 |
</section>'''
|
| 360 |
|
| 361 |
|
|
|
|
| 423 |
with gr.Accordion("About the tree and coverage", open=False, elem_classes="atlas-disclosure"):
|
| 424 |
gr.Markdown("This tree follows **NCBI Taxonomy within Eukaryota**. Animals and fungi share the Opisthokonta branch; "
|
| 425 |
"green plants include green algae. ‘Other lineages’ combines the remaining siblings for display and can be expanded. "
|
| 426 |
+
"Each expanded group adds a level below it with up to six direct lineages plus any remainder, so every ancestor stays visible "
|
| 427 |
+
"and a whole lineage reads as one page scroll. The opening view also expands the animal/fungal branch. Bars and flows "
|
| 428 |
+
"are drawn like a Sankey diagram: widths follow **actual assembly counts** within each level, split into annotated "
|
| 429 |
+
"and not-annotated assemblies.\n\n"
|
| 430 |
"Each group shows its scientific name with a plain-English name underneath where one is available. These come from "
|
| 431 |
"NCBI Taxonomy's common names plus a curated set of short glosses for the large unranked clades NCBI leaves unnamed, "
|
| 432 |
"such as Opisthokonta (animals and fungi) or Ecdysozoa (moulting animals). The glosses describe the living members of a "
|
tests/test_taxonomy.py
CHANGED
|
@@ -4,7 +4,7 @@ import sqlite3
|
|
| 4 |
import unittest
|
| 5 |
|
| 6 |
from refresh_common_names import CURATED
|
| 7 |
-
from taxonomy import COMMON_NAMES, DATABASE, DEFAULT_PATH, ICON_DIR, ICONS, MAX_ENGLISH, Taxonomy
|
| 8 |
|
| 9 |
|
| 10 |
@unittest.skipUnless(DATABASE.exists(), "taxonomy snapshot unavailable")
|
|
@@ -13,14 +13,18 @@ class TaxonomyTests(unittest.TestCase):
|
|
| 13 |
def setUpClass(cls):
|
| 14 |
cls.taxonomy = Taxonomy()
|
| 15 |
|
| 16 |
-
def
|
| 17 |
-
|
|
|
|
| 18 |
|
| 19 |
-
def
|
| 20 |
-
self.assertEqual(len(self.
|
| 21 |
-
self.assertEqual(len(self.
|
| 22 |
html = self.taxonomy.view("2759/33154/33208")
|
| 23 |
-
self.
|
|
|
|
|
|
|
|
|
|
| 24 |
self.assertEqual(html.count('class="tree-node is-selected"'), 3)
|
| 25 |
|
| 26 |
def test_search_path_expands_lineage(self):
|
|
@@ -100,13 +104,13 @@ class TaxonomyTests(unittest.TestCase):
|
|
| 100 |
self.skipTest("silhouettes unavailable")
|
| 101 |
for path in ("2759", DEFAULT_PATH, "2759/33154/33208", "2759/33154/33208/6072/33213"):
|
| 102 |
html = self.taxonomy.view(path)
|
| 103 |
-
#
|
| 104 |
-
drawn = re.findall(r'<image class="tree-icon" x="([\d.]+)"[^>]*?/data/icons/([0-9a-f-]+)\.svg"', html)
|
| 105 |
-
|
| 106 |
-
for
|
| 107 |
-
|
| 108 |
-
for
|
| 109 |
-
self.assertEqual(len(images), len(set(images)), f"{path}
|
| 110 |
|
| 111 |
def test_chart_references_icons_by_url(self):
|
| 112 |
if not self.taxonomy.icons:
|
|
@@ -122,8 +126,17 @@ class TaxonomyTests(unittest.TestCase):
|
|
| 122 |
for path in ("2759", DEFAULT_PATH, "2759/33154/33208"):
|
| 123 |
box = re.search(r'<svg viewBox="0 0 ([\d.]+) ([\d.]+)"', self.taxonomy.view(path))
|
| 124 |
self.assertIsNotNone(box, path)
|
| 125 |
-
self.assertGreater(float(box.group(1)),
|
| 126 |
-
self.assertGreater(float(box.group(2)),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
|
| 128 |
def test_rejects_paths_outside_the_tree(self):
|
| 129 |
for path in ("1", "33208", "2759/33208", "2759/2759~3", "2759/9606", "abc"):
|
|
|
|
| 4 |
import unittest
|
| 5 |
|
| 6 |
from refresh_common_names import CURATED
|
| 7 |
+
from taxonomy import COMMON_NAMES, DATABASE, DEFAULT_PATH, ICON_DIR, ICONS, MAX_ENGLISH, SHORTCUTS, Taxonomy
|
| 8 |
|
| 9 |
|
| 10 |
@unittest.skipUnless(DATABASE.exists(), "taxonomy snapshot unavailable")
|
|
|
|
| 13 |
def setUpClass(cls):
|
| 14 |
cls.taxonomy = Taxonomy()
|
| 15 |
|
| 16 |
+
def levels(self, html):
|
| 17 |
+
"""Each level sits at its own y; bars are horizontal with a fixed height."""
|
| 18 |
+
return sorted({float(y) for y in re.findall(r'<rect x="[\d.]+" y="([\d.]+)" width="[\d.]+" height="15" class="bar-outline"', html)})
|
| 19 |
|
| 20 |
+
def test_expansion_keeps_every_ancestor_level(self):
|
| 21 |
+
self.assertEqual(len(self.levels(self.taxonomy.view("2759"))), 2)
|
| 22 |
+
self.assertEqual(len(self.levels(self.taxonomy.view(DEFAULT_PATH))), 3)
|
| 23 |
html = self.taxonomy.view("2759/33154/33208")
|
| 24 |
+
levels = self.levels(html)
|
| 25 |
+
self.assertEqual(len(levels), 4)
|
| 26 |
+
# Levels run down the page, evenly spaced, so depth costs height not width.
|
| 27 |
+
self.assertEqual({round(b - a) for a, b in zip(levels, levels[1:])}, {172})
|
| 28 |
self.assertEqual(html.count('class="tree-node is-selected"'), 3)
|
| 29 |
|
| 30 |
def test_search_path_expands_lineage(self):
|
|
|
|
| 104 |
self.skipTest("silhouettes unavailable")
|
| 105 |
for path in ("2759", DEFAULT_PATH, "2759/33154/33208", "2759/33154/33208/6072/33213"):
|
| 106 |
html = self.taxonomy.view(path)
|
| 107 |
+
# Levels are laid out top to bottom, so group the icons by their y.
|
| 108 |
+
drawn = re.findall(r'<image class="tree-icon" x="[\d.]+" y="([\d.]+)"[^>]*?/data/icons/([0-9a-f-]+)\.svg"', html)
|
| 109 |
+
levels = {}
|
| 110 |
+
for y, image in drawn:
|
| 111 |
+
levels.setdefault(round(float(y)), []).append(image)
|
| 112 |
+
for y, images in levels.items():
|
| 113 |
+
self.assertEqual(len(images), len(set(images)), f"{path} level at {y}")
|
| 114 |
|
| 115 |
def test_chart_references_icons_by_url(self):
|
| 116 |
if not self.taxonomy.icons:
|
|
|
|
| 126 |
for path in ("2759", DEFAULT_PATH, "2759/33154/33208"):
|
| 127 |
box = re.search(r'<svg viewBox="0 0 ([\d.]+) ([\d.]+)"', self.taxonomy.view(path))
|
| 128 |
self.assertIsNotNone(box, path)
|
| 129 |
+
self.assertGreater(float(box.group(1)), 1000, path)
|
| 130 |
+
self.assertGreater(float(box.group(2)), 300, path)
|
| 131 |
+
|
| 132 |
+
def test_shortcuts_expand_their_whole_lineage(self):
|
| 133 |
+
html = self.taxonomy.view(DEFAULT_PATH)
|
| 134 |
+
for taxid, name in SHORTCUTS:
|
| 135 |
+
self.assertIn(f">{name}</button>", html)
|
| 136 |
+
path = self.taxonomy.taxon_path(taxid)
|
| 137 |
+
self.assertIn(f'data-path="{path}"', html)
|
| 138 |
+
self.assertTrue(path.startswith("2759/"), name)
|
| 139 |
+
self.assertTrue(path.endswith(f"/{taxid}"), name)
|
| 140 |
|
| 141 |
def test_rejects_paths_outside_the_tree(self):
|
| 142 |
for path in ("1", "33208", "2759/33208", "2759/2759~3", "2759/9606", "abc"):
|