Lift captions over the ribbons, light the inflowing flux, move counts to the tooltip
#5
by lvwerra HF Staff - opened
- atlas.css +10 -3
- atlas.js +15 -1
- data/icons.json +14 -12
- data/icons/675ddef0-9dd4-4eef-b63f-98beb30f98c7.svg +169 -0
- data/icons/c6066aa4-fadd-45e0-b897-b16b37977e8e.svg +0 -29
- refresh_icons.py +15 -1
- taxonomy.py +48 -40
- tests/test_taxonomy.py +30 -4
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)
|
| 49 |
-
.tree-node:not(.is-direct)
|
|
|
|
|
|
|
| 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":
|
| 15 |
},
|
| 16 |
"licenses": {
|
| 17 |
-
"https://creativecommons.org/licenses/by/4.0/":
|
| 18 |
-
"https://creativecommons.org/publicdomain/zero/1.0/":
|
| 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": "
|
| 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
|
| 198 |
-
|
|
|
|
| 199 |
"""
|
| 200 |
-
left, span, bar, top = 16, 1180, 15,
|
| 201 |
-
rung =
|
| 202 |
-
|
| 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,
|
| 207 |
-
|
| 208 |
width = left * 2 + span
|
| 209 |
-
height = top + (len(columns) - 1) * rung +
|
| 210 |
|
| 211 |
def spread(nodes):
|
| 212 |
-
"""Bar widths follow assembly counts, with room
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 246 |
-
|
| 247 |
-
n['
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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['
|
| 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}
|
|
|
|
| 287 |
centre = n['slot_x'] + n['slot'] / 2
|
| 288 |
-
nodes.append(f'<rect x="{n["slot_x"] - 5:.1f}" y="{y -
|
| 289 |
-
f'height="{
|
| 290 |
green = w * f
|
| 291 |
if green > 0:
|
| 292 |
-
nodes.append(f'<rect x="{x:.1f}" y="{
|
| 293 |
if green < w:
|
| 294 |
-
nodes.append(f'<rect x="{x + green:.1f}" y="{
|
| 295 |
-
nodes.append(f'<rect x="{x:.1f}" y="{
|
| 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
|
| 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 +
|
| 307 |
f'width="{icon_w:.1f}" height="{icon_h:.1f}" '
|
| 308 |
f'href="{ICON_ROUTE}{ICON_DIR / (n["icon"] + ".svg")}"/>')
|
| 309 |
-
|
| 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="{
|
| 313 |
-
nodes.append(
|
| 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
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)),
|
| 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)
|