Lift captions over the ribbons, light the inflowing flux, move counts to the tooltip

#5
by lvwerra HF Staff - opened
atlas.css CHANGED
@@ -26,6 +26,12 @@
26
  .flow { stroke:none; transition:opacity .15s; }
27
  .flow-annotated { fill:#40916f; opacity:.42; }
28
  .flow-missing { fill:#cfd5c9; opacity:.6; }
 
 
 
 
 
 
29
  .bar-annotated { fill:#287557; }
30
  .bar-missing { fill:#bcc4b5; }
31
  .bar-outline { fill:none; stroke:#f7f8f0; stroke-width:1; }
@@ -39,14 +45,15 @@
39
  .tree-node.is-sibling:hover,.tree-node.is-sibling:focus { opacity:1; }
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; }
47
  .tree-count { font:11px Arial,Helvetica,sans-serif; fill:#697966; }
48
- .tree-node:not(.is-direct):hover .tree-label,.tree-node:focus .tree-label { fill:#0f2e22; }
49
- .tree-node:not(.is-direct):hover .bar-outline { stroke:#287557; stroke-width:2; }
 
 
50
  .atlas-footer { display:flex; align-items:center; flex-wrap:wrap; gap:15px; justify-content:space-between; border-top:1px solid #dde4d5; padding-top:14px; color:#6c7a66; font-size:11px; line-height:1.6; }
51
  .atlas-legend { display:flex; gap:7px; align-items:center; }
52
  .atlas-swatch { width:10px; height:10px; border-radius:2px; }
 
26
  .flow { stroke:none; transition:opacity .15s; }
27
  .flow-annotated { fill:#40916f; opacity:.42; }
28
  .flow-missing { fill:#cfd5c9; opacity:.6; }
29
+ /* Lighting a group also lights the flux arriving at it, so you can see where
30
+ a group's assemblies came from. Applied from atlas.js, since the ribbons are
31
+ painted under every node and cannot be reached with a CSS sibling rule. */
32
+ .flow.is-lit { opacity:.9; }
33
+ .flow-annotated.is-lit { fill:#2f7d5c; }
34
+ .flow-missing.is-lit { fill:#b4bdac; }
35
  .bar-annotated { fill:#287557; }
36
  .bar-missing { fill:#bcc4b5; }
37
  .bar-outline { fill:none; stroke:#f7f8f0; stroke-width:1; }
 
45
  .tree-node.is-sibling:hover,.tree-node.is-sibling:focus { opacity:1; }
46
  .tree-icon { opacity:.62; transition:opacity .15s; }
47
  .tree-node.is-selected .tree-icon { opacity:.85; }
 
48
  .tree-label,.tree-english,.tree-count { paint-order:stroke; stroke:#f7f8f0; stroke-width:4px; stroke-linejoin:round; text-anchor:middle; }
49
  .tree-label { font:600 13px Arial,Helvetica,sans-serif; fill:#254735; transition:fill .15s; }
50
  .tree-node.is-selected .tree-label { font-weight:700; fill:#173c30; }
51
  .tree-english { font:italic 11.5px Georgia,'Times New Roman',serif; fill:#5d7a66; }
52
  .tree-count { font:11px Arial,Helvetica,sans-serif; fill:#697966; }
53
+ .tree-node.is-lit:not(.is-direct) .tree-label,.tree-node:focus .tree-label { fill:#0f2e22; }
54
+ .tree-node.is-lit:not(.is-direct) .bar-outline { stroke:#287557; stroke-width:2; }
55
+ .tree-node.is-lit { opacity:1; }
56
+ .tree-node.is-lit .tree-icon { opacity:.9; }
57
  .atlas-footer { display:flex; align-items:center; flex-wrap:wrap; gap:15px; justify-content:space-between; border-top:1px solid #dde4d5; padding-top:14px; color:#6c7a66; font-size:11px; line-height:1.6; }
58
  .atlas-legend { display:flex; gap:7px; align-items:center; }
59
  .atlas-swatch { width:10px; height:10px; border-radius:2px; }
atlas.js CHANGED
@@ -10,12 +10,25 @@ function activate(event) {
10
  }
11
  element.addEventListener('click', activate);
12
  element.addEventListener('keydown', activate);
 
 
 
 
 
 
 
 
 
 
 
 
13
  element.addEventListener('pointermove', (event) => {
14
  const node = event.target.closest('[data-detail]');
15
  const tooltip = element.querySelector('.atlas-tooltip');
16
  const atlas = element.querySelector('.atlas');
17
  if (!tooltip || !atlas) return;
18
- if (!node || event.pointerType === 'touch') { tooltip.hidden = true; return; }
 
19
  tooltip.textContent = node.dataset.detail;
20
  tooltip.hidden = false;
21
  const bounds = atlas.getBoundingClientRect();
@@ -25,6 +38,7 @@ element.addEventListener('pointermove', (event) => {
25
  element.addEventListener('pointerleave', () => {
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.
 
10
  }
11
  element.addEventListener('click', activate);
12
  element.addEventListener('keydown', activate);
13
+ // A group and the flux arriving at it share a data-node id, so hovering either
14
+ // one lights both. The ribbons are painted beneath every group, so no CSS
15
+ // sibling rule can reach them from the group being hovered.
16
+ let lit = '';
17
+ function light(uid) {
18
+ if (uid === lit) return;
19
+ lit = uid;
20
+ element.querySelectorAll('.is-lit').forEach((el) => el.classList.remove('is-lit'));
21
+ if (uid) element.querySelectorAll(`[data-node="${CSS.escape(uid)}"]`).forEach((el) => el.classList.add('is-lit'));
22
+ }
23
+ element.addEventListener('focusin', (event) => light(event.target.closest('[data-node]')?.dataset.node || ''));
24
+ element.addEventListener('focusout', () => light(''));
25
  element.addEventListener('pointermove', (event) => {
26
  const node = event.target.closest('[data-detail]');
27
  const tooltip = element.querySelector('.atlas-tooltip');
28
  const atlas = element.querySelector('.atlas');
29
  if (!tooltip || !atlas) return;
30
+ if (!node || event.pointerType === 'touch') { tooltip.hidden = true; light(''); return; }
31
+ light(node.dataset.node || '');
32
  tooltip.textContent = node.dataset.detail;
33
  tooltip.hidden = false;
34
  const bounds = atlas.getBoundingClientRect();
 
38
  element.addEventListener('pointerleave', () => {
39
  const tooltip = element.querySelector('.atlas-tooltip');
40
  if (tooltip) tooltip.hidden = true;
41
+ light('');
42
  });
43
  // Expanding adds a level below. The page scrolls, not the widget, so only nudge
44
  // it when the new bottom of the tree has fallen past the viewport.
data/icons.json CHANGED
@@ -8,14 +8,16 @@
8
  "primary_image": 557,
9
  "assigned": 701,
10
  "clade_fallback": 117,
 
11
  "unresolved": 392,
12
  "no_free_image": 4,
 
13
  "distinct_icons": 447,
14
- "bytes": 5891595
15
  },
16
  "licenses": {
17
- "https://creativecommons.org/licenses/by/4.0/": 59,
18
- "https://creativecommons.org/publicdomain/zero/1.0/": 297,
19
  "https://creativecommons.org/licenses/by/3.0/": 45,
20
  "https://creativecommons.org/publicdomain/mark/1.0/": 46
21
  },
@@ -1428,6 +1430,14 @@
1428
  "ratio": 1.082,
1429
  "bytes": 15234
1430
  },
 
 
 
 
 
 
 
 
1431
  "67a9ecfd-58ba-44a4-9986-243b6e610419": {
1432
  "license": "https://creativecommons.org/publicdomain/mark/1.0/",
1433
  "attribution": null,
@@ -2740,14 +2750,6 @@
2740
  "ratio": 1.292,
2741
  "bytes": 6037
2742
  },
2743
- "c6066aa4-fadd-45e0-b897-b16b37977e8e": {
2744
- "license": "https://creativecommons.org/licenses/by/4.0/",
2745
- "attribution": "Andrew JM Swafford",
2746
- "contributor": "Andrew JM Swafford",
2747
- "page": "https://www.phylopic.org/images/c6066aa4-fadd-45e0-b897-b16b37977e8e",
2748
- "ratio": 3.421,
2749
- "bytes": 1671
2750
- },
2751
  "c722e81c-d41b-43bb-958d-f64d50e39074": {
2752
  "license": "https://creativecommons.org/publicdomain/zero/1.0/",
2753
  "attribution": null,
@@ -3655,7 +3657,7 @@
3655
  "4609": "07778806-5dcf-4524-9d93-8ead16a51bf9",
3656
  "4618": "25b193dc-07bf-4e8d-b51d-9d3a12457c72",
3657
  "4734": "2af0a13e-69a8-4245-832e-ee3d981089b7",
3658
- "4751": "c6066aa4-fadd-45e0-b897-b16b37977e8e",
3659
  "4761": "48480ba6-95cd-4291-b80c-5d979c5da193",
3660
  "4762": "53b543aa-2a5e-4407-88cd-6acbb0b5c3f9",
3661
  "4763": "23f8258b-3144-447e-8486-f5e914a9a9f9",
 
8
  "primary_image": 557,
9
  "assigned": 701,
10
  "clade_fallback": 117,
11
+ "overridden": 1,
12
  "unresolved": 392,
13
  "no_free_image": 4,
14
+ "removed": 1,
15
  "distinct_icons": 447,
16
+ "bytes": 5902047
17
  },
18
  "licenses": {
19
+ "https://creativecommons.org/licenses/by/4.0/": 58,
20
+ "https://creativecommons.org/publicdomain/zero/1.0/": 298,
21
  "https://creativecommons.org/licenses/by/3.0/": 45,
22
  "https://creativecommons.org/publicdomain/mark/1.0/": 46
23
  },
 
1430
  "ratio": 1.082,
1431
  "bytes": 15234
1432
  },
1433
+ "675ddef0-9dd4-4eef-b63f-98beb30f98c7": {
1434
+ "license": "https://creativecommons.org/publicdomain/zero/1.0/",
1435
+ "attribution": "Kaldari and T. Michael Keesey",
1436
+ "contributor": "T. Michael Keesey",
1437
+ "page": "https://www.phylopic.org/images/675ddef0-9dd4-4eef-b63f-98beb30f98c7",
1438
+ "ratio": 0.979,
1439
+ "bytes": 12123
1440
+ },
1441
  "67a9ecfd-58ba-44a4-9986-243b6e610419": {
1442
  "license": "https://creativecommons.org/publicdomain/mark/1.0/",
1443
  "attribution": null,
 
2750
  "ratio": 1.292,
2751
  "bytes": 6037
2752
  },
 
 
 
 
 
 
 
 
2753
  "c722e81c-d41b-43bb-958d-f64d50e39074": {
2754
  "license": "https://creativecommons.org/publicdomain/zero/1.0/",
2755
  "attribution": null,
 
3657
  "4609": "07778806-5dcf-4524-9d93-8ead16a51bf9",
3658
  "4618": "25b193dc-07bf-4e8d-b51d-9d3a12457c72",
3659
  "4734": "2af0a13e-69a8-4245-832e-ee3d981089b7",
3660
+ "4751": "675ddef0-9dd4-4eef-b63f-98beb30f98c7",
3661
  "4761": "48480ba6-95cd-4291-b80c-5d979c5da193",
3662
  "4762": "53b543aa-2a5e-4407-88cd-6acbb0b5c3f9",
3663
  "4763": "23f8258b-3144-447e-8486-f5e914a9a9f9",
data/icons/675ddef0-9dd4-4eef-b63f-98beb30f98c7.svg ADDED
data/icons/c6066aa4-fadd-45e0-b897-b16b37977e8e.svg DELETED
refresh_icons.py CHANGED
@@ -32,6 +32,14 @@ EUKARYOTA = 2759
32
  COLUMN_LIMIT = 6
33
  # Groups below this many assemblies inherit an ancestor's icon at render time.
34
  MIN_ASSEMBLIES = 100
 
 
 
 
 
 
 
 
35
  # Columns within this many steps of the root get an icon regardless of size.
36
  SHALLOW_DEPTH = 5
37
  # The node's representative image almost always wins. Contact sheets of the
@@ -154,10 +162,12 @@ def build(database, icon_dir, output, minimum=MIN_ASSEMBLIES, pause=0.1):
154
  shapes[best["uuid"]] = best["ratio"]
155
  counts["primary_image" if is_primary else "clade_fallback"] += 1
156
  time.sleep(pause)
157
- image = nodes[node]
158
  if not image:
159
  counts["no_free_image"] += 1
160
  continue
 
 
161
  assignments[taxid] = image
162
  counts["assigned"] += 1
163
  if position % 100 == 0:
@@ -183,6 +193,10 @@ def build(database, icon_dir, output, minimum=MIN_ASSEMBLIES, pause=0.1):
183
  target.write_bytes(body)
184
  time.sleep(pause)
185
  keep.add(image)
 
 
 
 
186
  images[image] = {"license": links.get("license", {}).get("href"),
187
  "attribution": detail.get("attribution"),
188
  "contributor": links.get("contributor", {}).get("title"),
 
32
  COLUMN_LIMIT = 6
33
  # Groups below this many assemblies inherit an ancestor's icon at render time.
34
  MIN_ASSEMBLIES = 100
35
+ # PhyloPic's representative image is usually right, but for a few flagship
36
+ # groups it is accurate and still a poor mnemonic. Its image for the whole Fungi
37
+ # kingdom is a chytrid zoospore: chytrids are early-diverging fungi and the only
38
+ # ones that keep a flagellum, and that single posterior flagellum is the trait
39
+ # Opisthokonta is named for, which is why it reads as an animal sperm cell. True
40
+ # of the clade, useless as an icon, so a mushroom cluster stands in.
41
+ OVERRIDES = {4751: "675ddef0-9dd4-4eef-b63f-98beb30f98c7"}
42
+
43
  # Columns within this many steps of the root get an icon regardless of size.
44
  SHALLOW_DEPTH = 5
45
  # The node's representative image almost always wins. Contact sheets of the
 
162
  shapes[best["uuid"]] = best["ratio"]
163
  counts["primary_image" if is_primary else "clade_fallback"] += 1
164
  time.sleep(pause)
165
+ image = OVERRIDES.get(taxid) or nodes[node]
166
  if not image:
167
  counts["no_free_image"] += 1
168
  continue
169
+ if taxid in OVERRIDES:
170
+ counts["overridden"] += 1
171
  assignments[taxid] = image
172
  counts["assigned"] += 1
173
  if position % 100 == 0:
 
193
  target.write_bytes(body)
194
  time.sleep(pause)
195
  keep.add(image)
196
+ sizes = (links.get("rasterFiles") or [{}])[0].get("sizes", "")
197
+ if image not in shapes and "x" in sizes:
198
+ found = [int(part) for part in sizes.split("x")]
199
+ shapes[image] = found[0] / found[1] if found[1] else 1.0
200
  images[image] = {"license": links.get("license", {}).get("href"),
201
  "attribution": detail.get("attribution"),
202
  "contributor": links.get("contributor", {}).get("title"),
taxonomy.py CHANGED
@@ -194,22 +194,23 @@ class Taxonomy:
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):
@@ -234,17 +235,33 @@ class Taxonomy:
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]
@@ -258,11 +275,11 @@ class Taxonomy:
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,47 +287,38 @@ class Taxonomy:
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']
277
- detail = (f"{title} · {n['covered']:,} of {n['total']:,} assemblies have published annotations ({percent:.2f}%). Includes partial assemblies."
278
- if self.coverage else f"{title} · {n['total']:,} assemblies. Coverage inventory unavailable.")
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
 
 
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 caption sits above its bar, painted over the ribbons arriving there
198
+ rather than breaking them. Depth costs height, which the page can always
199
+ give, rather than width, which it cannot.
200
  """
201
+ left, span, bar, top = 16, 1180, 15, 10
202
+ rung = 152 # distance from one level's caption to the next
203
+ line = 15
204
  # Silhouettes are not square: they run from tall and thin to long and
205
  # flat. Fitting them all into one box turns most into slivers, so each
206
  # gets its own box with the same area and its own proportions.
207
+ icon_area, icon_max, icon_band = 24 * 24, 42, 46
208
+ caption = icon_band + 2 * line # silhouette, scientific name, English name
209
  width = left * 2 + span
210
+ height = top + (len(columns) - 1) * rung + caption + bar + 12
211
 
212
  def spread(nodes):
213
+ """Bar widths follow assembly counts, with room above each for its caption."""
214
  gap, slot = (14 if len(nodes) > 1 else 0), 148
215
  fixed, scale = set(), 0
216
  for _ in range(len(nodes) + 1):
 
235
  def share(n):
236
  return n['covered'] / n['total'] if self.coverage and n['total'] else 0
237
 
238
+ def describe(n):
239
+ """The counts live here rather than in the chart, which stays uncluttered."""
240
+ percent = 100 * share(n)
241
+ title = f"{n['name']} ({n['english']})" if n['english'] else n['name']
242
+ detail = (f"{title} · {n['covered']:,} of {n['total']:,} assemblies have published annotations ({percent:.2f}%). Includes partial assemblies."
243
+ if self.coverage else f"{title} · {n['total']:,} assemblies. Coverage inventory unavailable.")
244
+ if n.get('aggregate'):
245
+ detail += " Display aggregate, not a taxonomic clade. Activate to see its lineages."
246
+ elif not n.get('direct') and not n['selected']:
247
+ detail += " Activate to open this group below."
248
+ return detail
249
+
250
+ def ribbon(y0, a0, a1, y1, b0, b1, kind, n):
251
  bend = (y0 + y1) / 2
252
  return (f'<path d="M{a0:.1f},{y0} C{a0:.1f},{bend} {b0:.1f},{bend} {b0:.1f},{y1} '
253
+ f'L{b1:.1f},{y1} C{b1:.1f},{bend} {a1:.1f},{bend} {a1:.1f},{y0} Z" class="flow flow-{kind}" '
254
+ f'data-node="{n["uid"]}" data-detail="{escape(n["detail"], quote=True)}"/>')
255
 
 
256
  for depth, column in enumerate(columns):
257
  spread(column)
258
+ for index, n in enumerate(column):
259
+ n['y'] = top + depth * rung # top of the caption
260
+ n['bar_y'] = n['y'] + caption # top of the bar
261
+ n['uid'] = f"{depth}-{index}"
262
+ n['detail'] = describe(n)
263
+
264
+ flows, nodes = [], []
265
  for depth, column in enumerate(columns[:-1]):
266
  parent = next((n for n in column if n['selected']), None)
267
  children = columns[depth + 1]
 
275
  cursor = a1
276
  f = share(child)
277
  am, bm = a0 + f * (a1 - a0), child['x'] + f * child['w']
278
+ y0, y1 = parent['bar_y'] + bar, child['bar_y']
279
  if f > 0:
280
+ flows.append(ribbon(y0, a0, am, y1, child['x'], bm, "annotated", child))
281
  if f < 1:
282
+ flows.append(ribbon(y0, am, a1, y1, bm, child['x'] + child['w'], "missing", child))
283
 
284
  for depth, column in enumerate(columns):
285
  on_path = depth < len(steps)
 
287
  for n in column:
288
  if n['borrowed_icon'] and shown[n['icon']] > 1:
289
  n['icon'] = "" # identical borrowed outlines read as "same group"
290
+ x, y, w, bar_y = n['x'], n['y'], n['w'], n['bar_y']
291
  f = share(n)
 
 
 
 
 
 
 
 
292
  action = "" if n.get('direct') else f'data-path="{n["path"]}" role="button" tabindex="0"'
293
  classes = "tree-node" + (" is-selected" if n['selected'] else " is-sibling" if on_path else "") + \
294
  (" is-direct" if n.get('direct') else "")
295
+ nodes.append(f'<g class="{classes}" {action} data-node="{n["uid"]}" '
296
+ f'aria-label="{escape(n["detail"], quote=True)}" data-detail="{escape(n["detail"], quote=True)}">')
297
  centre = n['slot_x'] + n['slot'] / 2
298
+ nodes.append(f'<rect x="{n["slot_x"] - 5:.1f}" y="{y - 4}" width="{n["slot"] + 10:.1f}" '
299
+ f'height="{caption + bar + 8:.0f}" rx="12" class="tree-hit"/>')
300
  green = w * f
301
  if green > 0:
302
+ nodes.append(f'<rect x="{x:.1f}" y="{bar_y}" width="{green:.1f}" height="{bar}" class="bar bar-annotated"/>')
303
  if green < w:
304
+ nodes.append(f'<rect x="{x + green:.1f}" y="{bar_y}" width="{w - green:.1f}" height="{bar}" class="bar bar-missing"/>')
305
+ nodes.append(f'<rect x="{x:.1f}" y="{bar_y}" width="{w:.1f}" height="{bar}" class="bar-outline"/>')
306
  label, english = clip(n['label'], MAX_LABEL), clip(n['english'], MAX_ENGLISH)
 
307
  # The silhouette band is reserved whether or not this node has one,
308
+ # so names sit on the same baseline right across a level.
309
  if n['icon']:
310
  ratio = self.icon_credits.get(n['icon'], {}).get('ratio') or 1.0
311
  icon_h = min(icon_max, max(6, math.sqrt(icon_area / ratio)))
312
  icon_w = min(icon_max, max(6, icon_h * ratio))
313
  icon_h = min(icon_max, max(6, icon_w / ratio))
314
  nodes.append(f'<image class="tree-icon" x="{centre - icon_w / 2:.1f}" '
315
+ f'y="{y + (icon_band - icon_h) / 2:.1f}" '
316
  f'width="{icon_w:.1f}" height="{icon_h:.1f}" '
317
  f'href="{ICON_ROUTE}{ICON_DIR / (n["icon"] + ".svg")}"/>')
318
+ nodes.append(f'<text x="{centre:.1f}" y="{y + icon_band + line - 4:.0f}" class="tree-label">{escape(label)}</text>')
 
319
  if english:
320
+ nodes.append(f'<text x="{centre:.1f}" y="{y + icon_band + 2 * line - 4:.0f}" class="tree-english">{escape(english)}</text>')
321
+ nodes.append('</g>')
322
  svg = (f'<svg viewBox="0 0 {width} {height}" class="life-tree" data-route="{encode_path(steps)}" '
323
  f'aria-label="Eukaryotic taxonomy and annotation coverage">' + ''.join(flows) + ''.join(nodes) + '</svg>')
324
 
tests/test_taxonomy.py CHANGED
@@ -23,8 +23,12 @@ class TaxonomyTests(unittest.TestCase):
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):
@@ -52,7 +56,29 @@ class TaxonomyTests(unittest.TestCase):
52
  # <title> would render the browser's own tooltip on top of the styled one.
53
  self.assertNotIn("<title>", html)
54
  self.assertIn('data-detail="Viridiplantae (green plants) ·', html)
55
- self.assertEqual(html.count("data-detail="), html.count('class="tree-hit"'))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
 
57
  def test_curated_glosses_match_the_snapshot(self):
58
  with sqlite3.connect(f"{DATABASE.resolve().as_uri()}?mode=ro", uri=True) as conn:
@@ -127,7 +153,7 @@ class TaxonomyTests(unittest.TestCase):
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)
 
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 and clear of each other, so
27
+ # depth costs height rather than width.
28
+ pitches = {round(b - a) for a, b in zip(levels, levels[1:])}
29
+ self.assertEqual(len(pitches), 1, levels)
30
+ caption_and_bar = 46 + 2 * 15 + 15
31
+ self.assertGreater(pitches.pop(), caption_and_bar)
32
  self.assertEqual(html.count('class="tree-node is-selected"'), 3)
33
 
34
  def test_search_path_expands_lineage(self):
 
56
  # <title> would render the browser's own tooltip on top of the styled one.
57
  self.assertNotIn("<title>", html)
58
  self.assertIn('data-detail="Viridiplantae (green plants) ·', html)
59
+
60
+ def test_counts_live_in_the_tooltip_not_the_chart(self):
61
+ html = self.taxonomy.view(DEFAULT_PATH)
62
+ chart = html[html.index("<svg"):html.index("</svg>")]
63
+ drawn = re.findall(r'<text[^>]*>([^<]*)</text>', chart)
64
+ self.assertTrue(drawn)
65
+ for text in drawn:
66
+ self.assertNotRegex(text, r"\d,\d|%", f"counts belong in the tooltip, not the chart: {text}")
67
+ self.assertIn("33,594 of 70,395 assemblies", html)
68
+
69
+ def test_hovering_a_group_also_lights_its_inflowing_flux(self):
70
+ html = self.taxonomy.view("2759/33154/33208")
71
+ chart = html[html.index("<svg"):html.index("</svg>")]
72
+ flows = dict(re.findall(r'<path d="[^"]*" class="flow flow-\w+" data-node="([\d-]+)" data-detail="([^"]*)"', chart))
73
+ groups = dict(re.findall(r'<g class="tree-node[^"]*"[^>]*data-node="([\d-]+)"[^>]*data-detail="([^"]*)"', chart))
74
+ self.assertTrue(flows)
75
+ # Every ribbon carries the id of the group it arrives at, and the same text.
76
+ for uid, detail in flows.items():
77
+ self.assertIn(uid, groups, uid)
78
+ self.assertEqual(groups[uid], detail, uid)
79
+ # The root has nothing flowing into it; every other level does.
80
+ self.assertFalse(any(uid.startswith("0-") for uid in flows))
81
+ self.assertTrue(any(uid.startswith("1-") for uid in flows))
82
 
83
  def test_curated_glosses_match_the_snapshot(self):
84
  with sqlite3.connect(f"{DATABASE.resolve().as_uri()}?mode=ro", uri=True) as conn:
 
153
  box = re.search(r'<svg viewBox="0 0 ([\d.]+) ([\d.]+)"', self.taxonomy.view(path))
154
  self.assertIsNotNone(box, path)
155
  self.assertGreater(float(box.group(1)), 1000, path)
156
+ self.assertGreater(float(box.group(2)), 200, path)
157
 
158
  def test_shortcuts_expand_their_whole_lineage(self):
159
  html = self.taxonomy.view(DEFAULT_PATH)