|
| 1 | +""" pyplots.ai |
| 2 | +tree-phylogenetic: Phylogenetic Tree Diagram |
| 3 | +Library: highcharts unknown | Python 3.13.11 |
| 4 | +Quality: 90/100 | Created: 2025-12-31 |
| 5 | +""" |
| 6 | + |
| 7 | +import json |
| 8 | +import tempfile |
| 9 | +import time |
| 10 | +import urllib.request |
| 11 | +from pathlib import Path |
| 12 | + |
| 13 | +from selenium import webdriver |
| 14 | +from selenium.webdriver.chrome.options import Options |
| 15 | + |
| 16 | + |
| 17 | +# Data - Primate phylogenetic tree with branch lengths (MYA - Million Years Ago) |
| 18 | +# Branch lengths represent evolutionary distances |
| 19 | +phylo_data = { |
| 20 | + "nodes": { |
| 21 | + "Primates": {"name": "Primates", "rank": "Order", "depth": 0}, |
| 22 | + "Hominoidea": {"name": "Hominoidea", "rank": "Superfamily", "depth": 25}, |
| 23 | + "Cercopithecoidea": {"name": "Cercopithecoidea", "rank": "Superfamily", "depth": 25}, |
| 24 | + "Hominidae": {"name": "Hominidae", "rank": "Family", "depth": 40}, |
| 25 | + "Hylobatidae": {"name": "Hylobatidae", "rank": "Family", "depth": 40}, |
| 26 | + "Cercopithecidae": {"name": "Cercopithecidae", "rank": "Family", "depth": 40}, |
| 27 | + "Hominini": {"name": "Hominini", "rank": "Tribe", "depth": 55}, |
| 28 | + "Ponginae": {"name": "Ponginae", "rank": "Subfamily", "depth": 55}, |
| 29 | + "Cercopithecinae": {"name": "Cercopithecinae", "rank": "Subfamily", "depth": 55}, |
| 30 | + "Colobinae": {"name": "Colobinae", "rank": "Subfamily", "depth": 55}, |
| 31 | + "Homo_sapiens": {"name": "Homo sapiens", "rank": "Species", "depth": 75}, |
| 32 | + "Pan_troglodytes": {"name": "Pan troglodytes", "rank": "Species", "depth": 75}, |
| 33 | + "Pongo_pygmaeus": {"name": "Pongo pygmaeus", "rank": "Species", "depth": 75}, |
| 34 | + "Hylobates_lar": {"name": "Hylobates lar", "rank": "Species", "depth": 75}, |
| 35 | + "Symphalangus": {"name": "Symphalangus syndactylus", "rank": "Species", "depth": 75}, |
| 36 | + "Macaca_mulatta": {"name": "Macaca mulatta", "rank": "Species", "depth": 75}, |
| 37 | + "Papio_anubis": {"name": "Papio anubis", "rank": "Species", "depth": 75}, |
| 38 | + "Colobus_guereza": {"name": "Colobus guereza", "rank": "Species", "depth": 75}, |
| 39 | + "Nasalis_larvatus": {"name": "Nasalis larvatus", "rank": "Species", "depth": 75}, |
| 40 | + }, |
| 41 | + "edges": [ |
| 42 | + ("Primates", "Hominoidea"), |
| 43 | + ("Primates", "Cercopithecoidea"), |
| 44 | + ("Hominoidea", "Hominidae"), |
| 45 | + ("Hominoidea", "Hylobatidae"), |
| 46 | + ("Cercopithecoidea", "Cercopithecidae"), |
| 47 | + ("Hominidae", "Hominini"), |
| 48 | + ("Hominidae", "Ponginae"), |
| 49 | + ("Cercopithecidae", "Cercopithecinae"), |
| 50 | + ("Cercopithecidae", "Colobinae"), |
| 51 | + ("Hominini", "Homo_sapiens"), |
| 52 | + ("Hominini", "Pan_troglodytes"), |
| 53 | + ("Ponginae", "Pongo_pygmaeus"), |
| 54 | + ("Hylobatidae", "Hylobates_lar"), |
| 55 | + ("Hylobatidae", "Symphalangus"), |
| 56 | + ("Cercopithecinae", "Macaca_mulatta"), |
| 57 | + ("Cercopithecinae", "Papio_anubis"), |
| 58 | + ("Colobinae", "Colobus_guereza"), |
| 59 | + ("Colobinae", "Nasalis_larvatus"), |
| 60 | + ], |
| 61 | +} |
| 62 | + |
| 63 | + |
| 64 | +# Calculate Y positions for each node (vertical spacing) |
| 65 | +def assign_y_positions(phylo_data): |
| 66 | + """Assign vertical positions to nodes, with leaves evenly spaced.""" |
| 67 | + nodes = phylo_data["nodes"] |
| 68 | + edges = phylo_data["edges"] |
| 69 | + |
| 70 | + # Find leaf nodes (nodes with no children) |
| 71 | + parents = {e[0] for e in edges} |
| 72 | + leaves = [n for n in nodes if n not in parents] |
| 73 | + |
| 74 | + # Assign Y positions to leaves (evenly spaced) |
| 75 | + leaf_spacing = 100 / (len(leaves) + 1) |
| 76 | + for i, leaf in enumerate(leaves): |
| 77 | + nodes[leaf]["y"] = (i + 1) * leaf_spacing |
| 78 | + |
| 79 | + # Build parent-child map |
| 80 | + parent_map = {} |
| 81 | + for parent, child in edges: |
| 82 | + if parent not in parent_map: |
| 83 | + parent_map[parent] = [] |
| 84 | + parent_map[parent].append(child) |
| 85 | + |
| 86 | + # Propagate Y positions up (parent = mean of children) |
| 87 | + def get_y(node): |
| 88 | + if "y" in nodes[node]: |
| 89 | + return nodes[node]["y"] |
| 90 | + child_ys = [get_y(c) for c in parent_map.get(node, [])] |
| 91 | + nodes[node]["y"] = sum(child_ys) / len(child_ys) |
| 92 | + return nodes[node]["y"] |
| 93 | + |
| 94 | + for node in nodes: |
| 95 | + get_y(node) |
| 96 | + |
| 97 | + return nodes |
| 98 | + |
| 99 | + |
| 100 | +nodes = assign_y_positions(phylo_data) |
| 101 | + |
| 102 | +# Generate line data for branches (rectangular/cladogram style with proportional lengths) |
| 103 | +branch_lines = [] |
| 104 | +node_points = [] |
| 105 | + |
| 106 | +# Colors based on rank |
| 107 | +rank_colors = { |
| 108 | + "Order": "#1a365d", |
| 109 | + "Superfamily": "#2c5282", |
| 110 | + "Family": "#3182ce", |
| 111 | + "Tribe": "#63b3ed", |
| 112 | + "Subfamily": "#63b3ed", |
| 113 | + "Species": "#FFD43B", |
| 114 | +} |
| 115 | + |
| 116 | +for parent, child in phylo_data["edges"]: |
| 117 | + p_node = nodes[parent] |
| 118 | + c_node = nodes[child] |
| 119 | + # Horizontal line from parent to parent's x at child's y |
| 120 | + branch_lines.append( |
| 121 | + { |
| 122 | + "data": [[p_node["depth"], p_node["y"]], [p_node["depth"], c_node["y"]], [c_node["depth"], c_node["y"]]], |
| 123 | + "color": rank_colors.get(c_node["rank"], "#306998"), |
| 124 | + } |
| 125 | + ) |
| 126 | + |
| 127 | +# Create node markers |
| 128 | +for _node_id, node_data in nodes.items(): |
| 129 | + is_species = node_data["rank"] == "Species" |
| 130 | + node_points.append( |
| 131 | + { |
| 132 | + "x": node_data["depth"], |
| 133 | + "y": node_data["y"], |
| 134 | + "name": node_data["name"], |
| 135 | + "rank": node_data["rank"], |
| 136 | + "marker": { |
| 137 | + "symbol": "circle", |
| 138 | + "radius": 12 if is_species else 10, |
| 139 | + "fillColor": rank_colors.get(node_data["rank"], "#306998"), |
| 140 | + "lineWidth": 2, |
| 141 | + "lineColor": "#ffffff", |
| 142 | + }, |
| 143 | + "dataLabels": { |
| 144 | + "enabled": is_species, |
| 145 | + "format": "{point.name}", |
| 146 | + "align": "left", |
| 147 | + "x": 18, |
| 148 | + "style": {"fontSize": "28px", "fontWeight": "normal", "color": "#333333"}, |
| 149 | + }, |
| 150 | + } |
| 151 | + ) |
| 152 | + |
| 153 | +# Build series array - one line series per branch for proper colors |
| 154 | +series = [] |
| 155 | +for i, branch in enumerate(branch_lines): |
| 156 | + series.append( |
| 157 | + { |
| 158 | + "type": "line", |
| 159 | + "name": f"branch_{i}", |
| 160 | + "data": branch["data"], |
| 161 | + "color": branch["color"], |
| 162 | + "lineWidth": 4, |
| 163 | + "marker": {"enabled": False}, |
| 164 | + "enableMouseTracking": False, |
| 165 | + "showInLegend": False, |
| 166 | + } |
| 167 | + ) |
| 168 | + |
| 169 | +# Add node markers as scatter |
| 170 | +series.append( |
| 171 | + { |
| 172 | + "type": "scatter", |
| 173 | + "name": "Nodes", |
| 174 | + "data": node_points, |
| 175 | + "marker": {"radius": 10}, |
| 176 | + "tooltip": {"pointFormat": "<b>{point.name}</b><br/>Rank: {point.rank}"}, |
| 177 | + "showInLegend": False, |
| 178 | + } |
| 179 | +) |
| 180 | + |
| 181 | +# Add scale bar annotation (0-25 MYA scale bar in bottom right area) |
| 182 | +scale_bar_x = 50 |
| 183 | +scale_bar_y = 5 |
| 184 | +scale_length = 25 # 25 MYA |
| 185 | + |
| 186 | +# Highcharts configuration |
| 187 | +chart_config = { |
| 188 | + "chart": { |
| 189 | + "width": 4800, |
| 190 | + "height": 2700, |
| 191 | + "backgroundColor": "#ffffff", |
| 192 | + "marginTop": 180, |
| 193 | + "marginBottom": 180, |
| 194 | + "marginLeft": 200, |
| 195 | + "marginRight": 600, |
| 196 | + }, |
| 197 | + "title": { |
| 198 | + "text": "Primate Phylogeny · tree-phylogenetic · highcharts · pyplots.ai", |
| 199 | + "style": {"fontSize": "56px", "fontWeight": "bold"}, |
| 200 | + }, |
| 201 | + "subtitle": { |
| 202 | + "text": "Evolutionary relationships based on mitochondrial DNA divergence times", |
| 203 | + "style": {"fontSize": "36px", "color": "#666666"}, |
| 204 | + }, |
| 205 | + "credits": {"enabled": False}, |
| 206 | + "legend": {"enabled": False}, |
| 207 | + "xAxis": { |
| 208 | + "title": {"text": "Divergence Time (Million Years Ago)", "style": {"fontSize": "32px", "fontWeight": "bold"}}, |
| 209 | + "labels": {"style": {"fontSize": "26px"}}, |
| 210 | + "min": -5, |
| 211 | + "max": 85, |
| 212 | + "tickInterval": 10, |
| 213 | + "gridLineWidth": 1, |
| 214 | + "gridLineColor": "#e0e0e0", |
| 215 | + "reversed": True, # Root on right, species on left |
| 216 | + }, |
| 217 | + "yAxis": {"title": {"text": ""}, "labels": {"enabled": False}, "gridLineWidth": 0, "min": 0, "max": 100}, |
| 218 | + "tooltip": {"style": {"fontSize": "28px"}}, |
| 219 | + "plotOptions": { |
| 220 | + "series": {"animation": False}, |
| 221 | + "scatter": {"dataLabels": {"enabled": True, "style": {"fontSize": "28px", "textOutline": "2px white"}}}, |
| 222 | + }, |
| 223 | + "annotations": [ |
| 224 | + { |
| 225 | + "draggable": "", |
| 226 | + "labelOptions": {"backgroundColor": "transparent", "borderWidth": 0}, |
| 227 | + "labels": [ |
| 228 | + { |
| 229 | + "point": {"x": scale_bar_x, "y": scale_bar_y, "xAxis": 0, "yAxis": 0}, |
| 230 | + "text": f'<span style="font-size:26px;font-weight:bold;">Scale: {scale_length} MYA</span>', |
| 231 | + "useHTML": True, |
| 232 | + "y": -30, |
| 233 | + } |
| 234 | + ], |
| 235 | + "shapes": [ |
| 236 | + { |
| 237 | + "type": "path", |
| 238 | + "points": [ |
| 239 | + {"x": scale_bar_x, "y": scale_bar_y, "xAxis": 0, "yAxis": 0}, |
| 240 | + {"x": scale_bar_x - scale_length, "y": scale_bar_y, "xAxis": 0, "yAxis": 0}, |
| 241 | + ], |
| 242 | + "stroke": "#333333", |
| 243 | + "strokeWidth": 6, |
| 244 | + }, |
| 245 | + { |
| 246 | + "type": "path", |
| 247 | + "points": [ |
| 248 | + {"x": scale_bar_x, "y": scale_bar_y - 1, "xAxis": 0, "yAxis": 0}, |
| 249 | + {"x": scale_bar_x, "y": scale_bar_y + 1, "xAxis": 0, "yAxis": 0}, |
| 250 | + ], |
| 251 | + "stroke": "#333333", |
| 252 | + "strokeWidth": 6, |
| 253 | + }, |
| 254 | + { |
| 255 | + "type": "path", |
| 256 | + "points": [ |
| 257 | + {"x": scale_bar_x - scale_length, "y": scale_bar_y - 1, "xAxis": 0, "yAxis": 0}, |
| 258 | + {"x": scale_bar_x - scale_length, "y": scale_bar_y + 1, "xAxis": 0, "yAxis": 0}, |
| 259 | + ], |
| 260 | + "stroke": "#333333", |
| 261 | + "strokeWidth": 6, |
| 262 | + }, |
| 263 | + ], |
| 264 | + } |
| 265 | + ], |
| 266 | + "series": series, |
| 267 | +} |
| 268 | + |
| 269 | +# Convert config to JSON |
| 270 | +config_json = json.dumps(chart_config) |
| 271 | + |
| 272 | +# Download Highcharts JS and required modules |
| 273 | +modules = [ |
| 274 | + ("highcharts", "https://code.highcharts.com/highcharts.js"), |
| 275 | + ("annotations", "https://code.highcharts.com/modules/annotations.js"), |
| 276 | +] |
| 277 | + |
| 278 | +js_modules = {} |
| 279 | +for name, url in modules: |
| 280 | + with urllib.request.urlopen(url, timeout=30) as response: |
| 281 | + js_modules[name] = response.read().decode("utf-8") |
| 282 | + |
| 283 | +# Generate HTML with inline scripts |
| 284 | +html_content = f"""<!DOCTYPE html> |
| 285 | +<html> |
| 286 | +<head> |
| 287 | + <meta charset="utf-8"> |
| 288 | + <script>{js_modules["highcharts"]}</script> |
| 289 | + <script>{js_modules["annotations"]}</script> |
| 290 | +</head> |
| 291 | +<body style="margin:0;"> |
| 292 | + <div id="container" style="width: 4800px; height: 2700px;"></div> |
| 293 | + <script> |
| 294 | + Highcharts.chart('container', {config_json}); |
| 295 | + </script> |
| 296 | +</body> |
| 297 | +</html>""" |
| 298 | + |
| 299 | +# Save HTML file |
| 300 | +with open("plot.html", "w", encoding="utf-8") as f: |
| 301 | + f.write(html_content) |
| 302 | + |
| 303 | +# Write temp HTML and take screenshot |
| 304 | +with tempfile.NamedTemporaryFile(mode="w", suffix=".html", delete=False, encoding="utf-8") as f: |
| 305 | + f.write(html_content) |
| 306 | + temp_path = f.name |
| 307 | + |
| 308 | +chrome_options = Options() |
| 309 | +chrome_options.add_argument("--headless") |
| 310 | +chrome_options.add_argument("--no-sandbox") |
| 311 | +chrome_options.add_argument("--disable-dev-shm-usage") |
| 312 | +chrome_options.add_argument("--disable-gpu") |
| 313 | +chrome_options.add_argument("--window-size=4800,2800") |
| 314 | + |
| 315 | +driver = webdriver.Chrome(options=chrome_options) |
| 316 | +driver.get(f"file://{temp_path}") |
| 317 | +time.sleep(6) |
| 318 | + |
| 319 | +# Get container element and screenshot it |
| 320 | +container = driver.find_element("id", "container") |
| 321 | +container.screenshot("plot.png") |
| 322 | +driver.quit() |
| 323 | + |
| 324 | +Path(temp_path).unlink() |
0 commit comments