@@ -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