Skip to content

Commit 70eeddd

Browse files
feat(highcharts): implement tree-phylogenetic (#3118)
## Implementation: `tree-phylogenetic` - highcharts Implements the **highcharts** version of `tree-phylogenetic`. **File:** `plots/tree-phylogenetic/implementations/highcharts.py` **Parent Issue:** #3070 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/pyplots/actions/runs/20620338253)* --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent 32b5782 commit 70eeddd

2 files changed

Lines changed: 350 additions & 0 deletions

File tree

Lines changed: 324 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,324 @@
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()
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
library: highcharts
2+
specification_id: tree-phylogenetic
3+
created: '2025-12-31T14:01:02Z'
4+
updated: '2025-12-31T14:49:28Z'
5+
generated_by: claude-opus-4-5-20251101
6+
workflow_run: 20620338253
7+
issue: 3070
8+
python_version: 3.13.11
9+
library_version: unknown
10+
preview_url: https://storage.googleapis.com/pyplots-images/plots/tree-phylogenetic/highcharts/plot.png
11+
preview_thumb: https://storage.googleapis.com/pyplots-images/plots/tree-phylogenetic/highcharts/plot_thumb.png
12+
preview_html: https://storage.googleapis.com/pyplots-images/plots/tree-phylogenetic/highcharts/plot.html
13+
quality_score: 90
14+
review:
15+
strengths:
16+
- Excellent biological accuracy with real primate species and realistic divergence
17+
times
18+
- Clean rectangular cladogram layout with proportional branch lengths
19+
- Colorblind-safe blue-yellow palette distinguishing taxonomic ranks from species
20+
- Scale bar properly implemented using Highcharts annotations
21+
- Good canvas utilization with balanced margins and readable species labels
22+
- Reversed x-axis correctly shows time going back from present (0) to past (80 MYA)
23+
weaknesses:
24+
- Missing legend to explain the meaning of different blue shades (taxonomic ranks)
25+
- Code uses helper function (assign_y_positions) instead of pure KISS flat structure
26+
- Grid lines for Y-axis are hidden but could show subtle reference lines

0 commit comments

Comments
 (0)