Turn the Sankey down the page and add lineage shortcuts

#4
by lvwerra HF Staff - opened
Files changed (7) hide show
  1. README.md +1 -1
  2. atlas.css +6 -4
  3. atlas.js +12 -3
  4. data/common_names.json +6 -6
  5. refresh_common_names.py +5 -5
  6. taxonomy.py +72 -56
  7. 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 { overflow-x:auto; overscroll-behavior-x:contain; 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,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
- // Newly expanded columns appear on the right; keep them in view after each update.
 
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
- const scroller = element.querySelector('.tree-scroll');
36
- if (scroller) requestAnimationFrame(() => { scroller.scrollLeft = scroller.scrollWidth; });
 
 
 
 
 
 
 
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-07T13:47:40.354639+00:00",
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 and ungulates",
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 and wheat grasses",
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 and crocodiles",
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 land vertebrates",
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 relatives",
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 relatives"),
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 land vertebrates"),
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 and crocodiles"),
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 and ungulates"),
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 and wheat grasses"),
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 = 30
32
- MAX_LABEL = 24
 
 
 
 
 
 
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 in (33208, 33090, 4751)}
185
  return self.render(columns, steps, lineage, shortcuts, has_children=bool(tail))
186
 
187
  def render(self, columns, steps, lineage, shortcuts, has_children):
188
- top, chart, spacing, bar = 24, 460, 274, 14
 
 
 
 
 
 
 
 
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, gutter = 23 * 23, 40, 46
193
- width = max(830, 40 + (len(columns) - 1) * spacing + bar + 250)
194
- height = top * 2 + chart
195
-
196
- def stack(nodes):
197
- """Sankey layout: bar heights follow assembly counts, with room for every label."""
198
- gap, slot = (12 if len(nodes) > 1 else 0), 46
 
199
  fixed, scale = set(), 0
200
  for _ in range(len(nodes) + 1):
201
- free = chart - gap * (len(nodes) - 1) - slot * len(fixed)
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
- y = top
209
  for k, n in enumerate(nodes):
210
  room = slot if k in fixed else n['total'] * scale
211
- n['h'] = max(3, n['total'] * scale)
212
- n['y'] = y + (room - n['h']) / 2
213
- y += room + gap
 
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(x0, a0, a1, x1, b0, b1, kind):
222
- bend = (x0 + x1) / 2
223
- return (f'<path d="M{x0},{a0:.1f} C{bend},{a0:.1f} {bend},{b0:.1f} {x1},{b0:.1f} '
224
- f'L{x1},{b1:.1f} C{bend},{b1:.1f} {bend},{a1:.1f} {x0},{a1:.1f} Z" class="flow flow-{kind}"/>')
225
 
226
  flows, nodes = [], []
227
  for depth, column in enumerate(columns):
228
- stack(column)
229
- x = 40 + depth * spacing
230
  for n in column:
231
- n['x'] = x
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['y']
239
  for child in children:
240
  a0 = cursor
241
- a1 = cursor + parent['h'] * child['total'] / total
242
  cursor = a1
243
  f = share(child)
244
- am, bm = a0 + f * (a1 - a0), child['y'] + f * child['h']
245
- x0, x1 = parent['x'] + bar, child['x']
246
  if f > 0:
247
- flows.append(ribbon(x0, a0, am, x1, child['y'], bm, "annotated"))
248
  if f < 1:
249
- flows.append(ribbon(x0, am, a1, x1, bm, child['y'] + child['h'], "missing"))
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, h = n['x'], n['y'], n['h']
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 expand this group to the right."
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
- cy = y + h / 2
272
- nodes.append(f'<rect x="{x - 6}" y="{min(y, cy - 23):.1f}" width="{spacing - 40}" height="{max(h, 46):.1f}" rx="10" class="tree-hit"/>')
273
- green = h * f
 
274
  if green > 0:
275
- nodes.append(f'<rect x="{x}" y="{y:.1f}" width="{bar}" height="{green:.1f}" class="bar bar-annotated"/>')
276
- if green < h:
277
- nodes.append(f'<rect x="{x}" y="{y + green:.1f}" width="{bar}" height="{h - green:.1f}" class="bar bar-missing"/>')
278
- nodes.append(f'<rect x="{x}" y="{y:.1f}" width="{bar}" height="{h:.1f}" class="bar-outline"/>')
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 gutter is reserved whether or not this node has a silhouette, so
282
- # labels stay aligned down the column.
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="{x + bar + 8 + (gutter - 12 - icon_w) / 2:.1f}" y="{cy - icon_h / 2:.1f}" '
 
289
  f'width="{icon_w:.1f}" height="{icon_h:.1f}" '
290
  f'href="{ICON_ROUTE}{ICON_DIR / (n["icon"] + ".svg")}"/>')
291
- text_x = x + bar + 8 + gutter
292
- # Three lines when there is an English gloss, two without, both centred on the bar.
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="{text_x}" y="{cy + 3:.1f}" class="tree-english">{escape(english)}</text>')
297
- nodes.append(f'<text x="{text_x}" y="{stat_line:.1f}" class="tree-count">{stats}</text></g>')
298
- svg = (f'<svg viewBox="0 0 {width} {height}" class="life-tree" data-route="{encode_path(steps)}" style="min-width:{width if width > 1040 else 760}px" '
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 expand it to the right <span class="atlas-mobile-hint">· Swipe to explore</span></span>
342
  <span class="atlas-date">Updated {stamp}</span></footer>
343
- <div class="atlas-footnote">Bar and flow heights are proportional to assembly counts within each column; each column is rescaled to fill the height, so compare sizes within a column. Coverage counts assemblies with published annotations, including partial assemblies.</div>
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 column on the right with up to six direct lineages plus any remainder, so every ancestor stays visible. "
412
- "The opening view also expands the animal/fungal branch. Bars and flows are drawn like a Sankey diagram: "
413
- "heights follow **actual assembly counts** within each column, split into annotated and not-annotated assemblies.\n\n"
 
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 columns(self, html):
17
- return sorted({float(x) for x in re.findall(r'<rect x="([\d.]+)" y="[\d.]+" width="14"', html)})
 
18
 
19
- def test_expansion_keeps_every_ancestor_column(self):
20
- self.assertEqual(len(self.columns(self.taxonomy.view("2759"))), 2)
21
- self.assertEqual(len(self.columns(self.taxonomy.view(DEFAULT_PATH))), 3)
22
  html = self.taxonomy.view("2759/33154/33208")
23
- self.assertEqual(len(self.columns(html)), 4)
 
 
 
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
- # Columns are laid out left to right, so group the icons by their x.
104
- drawn = re.findall(r'<image class="tree-icon" x="([\d.]+)"[^>]*?/data/icons/([0-9a-f-]+)\.svg"', html)
105
- columns = {}
106
- for x, image in drawn:
107
- columns.setdefault(round(float(x)), []).append(image)
108
- for x, images in columns.items():
109
- self.assertEqual(len(images), len(set(images)), f"{path} column at {x}")
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)), 500, path)
126
- self.assertGreater(float(box.group(2)), 400, path)
 
 
 
 
 
 
 
 
 
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"):