Skip to content

Commit 148ee94

Browse files
committed
display of hierarchies
1 parent 747412f commit 148ee94

3 files changed

Lines changed: 254 additions & 0 deletions

File tree

src/test/test_hierarchy.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,39 @@ def test_mushroom_body_part_of_protocerebrum(self):
111111
assert 'FBbt_00003627' in ancestor_ids # protocerebrum
112112

113113

114+
class TestDisplayOutput:
115+
@pytest.mark.integration
116+
def test_display_field_present(self):
117+
result = get_hierarchy(KENYON_CELL, 'subclass_of', 'both', max_depth=1)
118+
assert 'display' in result
119+
assert isinstance(result['display'], str)
120+
assert 'Kenyon cell' in result['display']
121+
122+
@pytest.mark.integration
123+
def test_display_shows_ancestors(self):
124+
result = get_hierarchy(KENYON_CELL, 'subclass_of', 'both', max_depth=1)
125+
assert 'ancestors' in result['display'].lower()
126+
assert 'mushroom body intrinsic neuron' in result['display']
127+
128+
@pytest.mark.integration
129+
def test_display_shows_tree_connectors(self):
130+
result = get_hierarchy(KENYON_CELL, 'subclass_of', 'descendants', max_depth=1)
131+
assert '├──' in result['display'] or '└──' in result['display']
132+
133+
@pytest.mark.integration
134+
def test_html_field_present(self):
135+
result = get_hierarchy(KENYON_CELL, 'subclass_of', 'both', max_depth=1)
136+
assert 'html' in result
137+
assert '<!DOCTYPE html>' in result['html']
138+
assert 'Kenyon cell' in result['html']
139+
140+
@pytest.mark.integration
141+
def test_html_contains_vfb_links(self):
142+
result = get_hierarchy(KENYON_CELL, 'subclass_of', 'descendants', max_depth=1)
143+
assert 'virtualflybrain.org' in result['html']
144+
assert KENYON_CELL in result['html']
145+
146+
114147
class TestBothDirections:
115148
@pytest.mark.integration
116149
def test_both_returns_ancestors_and_descendants(self):

src/vfbquery/ha_api.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -930,6 +930,37 @@ async def handle_get_hierarchy(request):
930930
)
931931

932932

933+
async def handle_get_hierarchy_html(request):
934+
"""GET /get_hierarchy_html?id=FBbt_00005801&relationship=part_of&direction=both&max_depth=1
935+
936+
Serves the hierarchy as a self-contained HTML page (Content-Type: text/html).
937+
"""
938+
short_form = request.query.get("id")
939+
if not short_form:
940+
return web.Response(text="Error: id parameter is required", status=400)
941+
relationship = request.query.get("relationship", "part_of")
942+
if relationship not in ("part_of", "subclass_of"):
943+
return web.Response(text="Error: relationship must be 'part_of' or 'subclass_of'", status=400)
944+
direction = request.query.get("direction", "both")
945+
if direction not in ("descendants", "ancestors", "both"):
946+
return web.Response(text="Error: direction must be 'descendants', 'ancestors', or 'both'", status=400)
947+
max_depth = int(request.query.get("max_depth", "1"))
948+
949+
key = f"get_hierarchy:{short_form}:{relationship}:{direction}:{max_depth}"
950+
json_response = await _dispatch_to_pool(
951+
request, key, _run_get_hierarchy,
952+
short_form, relationship, direction, max_depth,
953+
)
954+
955+
# Extract HTML from the JSON result
956+
import json as _json
957+
result = _json.loads(json_response.body)
958+
html = result.get("html", "")
959+
if not html:
960+
return web.Response(text="No hierarchy data found", status=404)
961+
return web.Response(text=html, content_type="text/html")
962+
963+
933964
# ---------------------------------------------------------------------------
934965
# Application factory
935966
# ---------------------------------------------------------------------------
@@ -971,6 +1002,7 @@ def create_app(max_workers=None, max_concurrent=None, max_queue_depth=None,
9711002
app.router.add_get("/list_connectome_datasets", handle_list_connectome_datasets)
9721003
app.router.add_get("/query_connectivity", handle_query_connectivity)
9731004
app.router.add_get("/get_hierarchy", handle_get_hierarchy)
1005+
app.router.add_get("/get_hierarchy_html", handle_get_hierarchy_html)
9741006

9751007
# Store config for /status and handlers
9761008
app["max_workers"] = max_workers

src/vfbquery/vfb_queries.py

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4957,4 +4957,193 @@ def build(node_id, depth):
49574957
else:
49584958
root['ancestors'] = _build_ancestors_part_of(short_form)
49594959

4960+
# ------------------------------------------------------------------
4961+
# Render display text and HTML
4962+
# ------------------------------------------------------------------
4963+
4964+
VFB_BASE = 'https://v2.virtualflybrain.org/org.geppetto.frontend/geppetto?id='
4965+
DEFAULT_MAX_SIBLINGS = 10 # truncate large sibling groups in text display
4966+
4967+
def _text_tree(node, prefix='', is_last=True, is_root=True, max_siblings=DEFAULT_MAX_SIBLINGS):
4968+
"""Render a node and its descendants as a text tree."""
4969+
lines = []
4970+
label = f'{node["label"]} ({node["id"]})'
4971+
if is_root:
4972+
lines.append(label)
4973+
else:
4974+
lines.append(prefix + ('└── ' if is_last else '├── ') + label)
4975+
child_prefix = prefix + (' ' if is_last else '│ ')
4976+
children = node.get('descendants', [])
4977+
for i, child in enumerate(children):
4978+
if max_siblings is not None and len(children) > max_siblings and i == max_siblings - 2:
4979+
lines.append(child_prefix + f'├── ... ({len(children) - max_siblings + 1} more)')
4980+
lines.extend(_text_tree(children[-1], child_prefix, True, False, max_siblings))
4981+
break
4982+
lines.extend(_text_tree(child, child_prefix, i == len(children) - 1, False, max_siblings))
4983+
return lines
4984+
4985+
def _invert_ancestor_tree(ancestors, leaf_node):
4986+
"""Invert ancestor tree so highest-level terms are roots and the query term is a leaf.
4987+
4988+
Returns a list of top-level nodes, each with 'descendants' pointing downward
4989+
toward the query term.
4990+
"""
4991+
def _collect_roots(ancestors):
4992+
"""Find the top-level ancestors (those with no further ancestors)."""
4993+
roots = []
4994+
for a in ancestors:
4995+
if 'ancestors' in a and a['ancestors']:
4996+
roots.extend(_collect_roots(a['ancestors']))
4997+
else:
4998+
roots.append(a)
4999+
return roots
5000+
5001+
def _build_inverted(node, ancestors, target_leaf):
5002+
"""Build downward tree from an ancestor node toward the target leaf."""
5003+
# Find which of the ancestors list directly to this node
5004+
children_toward_leaf = []
5005+
for a in ancestors:
5006+
if 'ancestors' in a and a['ancestors']:
5007+
for grandparent in a['ancestors']:
5008+
if grandparent['id'] == node['id']:
5009+
children_toward_leaf.append(a)
5010+
elif a['id'] == node['id']:
5011+
# This ancestor IS the current node — leaf's direct parent
5012+
pass
5013+
5014+
result = {'id': node['id'], 'label': node['label']}
5015+
if children_toward_leaf:
5016+
result['descendants'] = [
5017+
_build_inverted(c, ancestors, target_leaf)
5018+
for c in sorted(children_toward_leaf, key=lambda x: x.get('label', ''))
5019+
]
5020+
else:
5021+
# This node's child is the query term itself
5022+
result['descendants'] = [leaf_node]
5023+
return result
5024+
5025+
# Collect all ancestor nodes into a flat list with their parent links
5026+
all_nodes = {} # id -> node
5027+
parent_map = {} # child_id -> set of parent_ids
5028+
5029+
def _walk(ancestors, child_id=None):
5030+
for a in ancestors:
5031+
all_nodes[a['id']] = {'id': a['id'], 'label': a['label']}
5032+
if child_id:
5033+
parent_map.setdefault(child_id, set()).add(a['id'])
5034+
if 'ancestors' in a and a['ancestors']:
5035+
_walk(a['ancestors'], a['id'])
5036+
5037+
_walk(ancestors, leaf_node['id'])
5038+
5039+
# Roots are nodes that aren't children of anything
5040+
all_children = set()
5041+
for children in parent_map.values():
5042+
all_children.update(children)
5043+
all_parents = set(parent_map.keys())
5044+
root_ids = all_children - all_parents
5045+
5046+
if not root_ids:
5047+
# Fallback: all direct ancestors are roots
5048+
root_ids = {a['id'] for a in ancestors}
5049+
5050+
# Add leaf node to all_nodes so its label is available
5051+
all_nodes[leaf_node['id']] = leaf_node
5052+
5053+
# Build downward trees from each root
5054+
def _build_down(node_id):
5055+
node = {'id': node_id, 'label': all_nodes.get(node_id, {}).get('label', node_id)}
5056+
children_ids = [cid for cid, pids in parent_map.items() if node_id in pids]
5057+
if children_ids:
5058+
node['descendants'] = [
5059+
_build_down(cid)
5060+
for cid in sorted(children_ids, key=lambda x: all_nodes.get(x, {}).get('label', x))
5061+
]
5062+
return node
5063+
5064+
return [_build_down(rid) for rid in sorted(root_ids, key=lambda x: all_nodes.get(x, {}).get('label', x))]
5065+
5066+
display_lines = []
5067+
if 'ancestors' in root and root['ancestors']:
5068+
rel_label = 'Part of' if relationship == 'part_of' else 'Is a'
5069+
display_lines.append(f'{rel_label} (ancestors):')
5070+
inverted = _invert_ancestor_tree(root['ancestors'], {'id': root['id'], 'label': root['label']})
5071+
for node in inverted:
5072+
display_lines.extend(_text_tree(node))
5073+
display_lines.append('')
5074+
5075+
if 'descendants' in root:
5076+
rel_label = 'Has parts' if relationship == 'part_of' else 'Subtypes'
5077+
display_lines.append(f'{rel_label} (descendants):')
5078+
display_lines.extend(_text_tree(root))
5079+
5080+
root['display'] = '\n'.join(display_lines)
5081+
5082+
# Full display (no sibling truncation)
5083+
full_lines = []
5084+
if 'ancestors' in root and root['ancestors']:
5085+
rel_label = 'Part of' if relationship == 'part_of' else 'Is a'
5086+
full_lines.append(f'{rel_label} (ancestors):')
5087+
inverted_full = _invert_ancestor_tree(root['ancestors'], {'id': root['id'], 'label': root['label']})
5088+
for node in inverted_full:
5089+
full_lines.extend(_text_tree(node, max_siblings=None))
5090+
full_lines.append('')
5091+
5092+
if 'descendants' in root:
5093+
rel_label = 'Has parts' if relationship == 'part_of' else 'Subtypes'
5094+
full_lines.append(f'{rel_label} (descendants):')
5095+
full_lines.extend(_text_tree(root, max_siblings=None))
5096+
5097+
root['display_full'] = '\n'.join(full_lines)
5098+
5099+
# HTML rendering
5100+
def _html_tree_nodes(node, depth=0, key='descendants'):
5101+
"""Render a node as nested HTML list items."""
5102+
sid = node['id']
5103+
label = node['label']
5104+
link = f'<a href="{VFB_BASE}{sid}" target="_blank">{label}</a> <span class="id">({sid})</span>'
5105+
children = node.get(key, [])
5106+
if not children:
5107+
return f'<li><details class="leaf"><summary>{link}</summary></details></li>'
5108+
items = ''.join(_html_tree_nodes(c, depth + 1, key) for c in children)
5109+
return f'<li><details{"" if depth > 1 else " open"}><summary>{link}</summary><ul>{items}</ul></details></li>'
5110+
5111+
html_parts = [
5112+
'<!DOCTYPE html><html><head><meta charset="utf-8">',
5113+
f'<title>Hierarchy: {root["label"]}</title>',
5114+
'<style>',
5115+
'body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; margin: 2em; max-width: 900px; line-height: 1.5; color: #24292e; }',
5116+
'h1 { font-size: 1.4em; border-bottom: 1px solid #e1e4e8; padding-bottom: .3em; }',
5117+
'h2 { font-size: 1.1em; margin-top: 1.5em; color: #586069; }',
5118+
'ul { list-style: none; padding-left: 1.5em; }',
5119+
'li { margin: .2em 0; }',
5120+
'details > summary { cursor: pointer; }',
5121+
'details > summary:hover { color: #0366d6; }',
5122+
'details.leaf > summary { list-style-type: "· "; cursor: default; }',
5123+
'details.leaf > summary::-webkit-details-marker { display: none; }',
5124+
'a { color: #0366d6; text-decoration: none; }',
5125+
'a:hover { text-decoration: underline; }',
5126+
'.id { color: #6a737d; font-size: .85em; }',
5127+
'.path { background: #f6f8fa; padding: .8em 1em; border-radius: 6px; margin: 1em 0; font-size: .95em; }',
5128+
'.path a { font-weight: 500; }',
5129+
'</style></head><body>',
5130+
f'<h1>{root["label"]} <span class="id">({root["id"]})</span></h1>',
5131+
]
5132+
5133+
if 'ancestors' in root and root['ancestors']:
5134+
rel_label = 'Part of' if relationship == 'part_of' else 'Is a'
5135+
html_parts.append(f'<h2>{rel_label} (ancestors)</h2>')
5136+
inverted_html = _invert_ancestor_tree(root['ancestors'], {'id': root['id'], 'label': root['label']})
5137+
items = ''.join(_html_tree_nodes(n) for n in inverted_html)
5138+
html_parts.append(f'<ul>{items}</ul>')
5139+
5140+
if 'descendants' in root and root['descendants']:
5141+
rel_label = 'Has parts' if relationship == 'part_of' else 'Subtypes'
5142+
html_parts.append(f'<h2>{rel_label} (descendants)</h2>')
5143+
root_node_html = _html_tree_nodes({'id': root['id'], 'label': root['label'], 'descendants': root['descendants']})
5144+
html_parts.append(f'<ul>{root_node_html}</ul>')
5145+
5146+
html_parts.append('</body></html>')
5147+
root['html'] = '\n'.join(html_parts)
5148+
49605149
return root

0 commit comments

Comments
 (0)