Skip to content

Commit 1f93f52

Browse files
feat(highcharts): implement column-stratigraphic (#4920)
## Implementation: `column-stratigraphic` - highcharts Implements the **highcharts** version of `column-stratigraphic`. **File:** `plots/column-stratigraphic/implementations/highcharts.py` **Parent Issue:** #4573 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/pyplots/actions/runs/23121199001)* --------- 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 e56ed80 commit 1f93f52

2 files changed

Lines changed: 579 additions & 0 deletions

File tree

Lines changed: 351 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,351 @@
1+
""" pyplots.ai
2+
column-stratigraphic: Stratigraphic Column with Lithology Patterns
3+
Library: highcharts unknown | Python 3.14.3
4+
Quality: 90/100 | Created: 2026-03-15
5+
"""
6+
7+
import json
8+
import tempfile
9+
import time
10+
import urllib.request
11+
from collections import OrderedDict
12+
from pathlib import Path
13+
14+
from highcharts_core.chart import Chart
15+
from selenium import webdriver
16+
from selenium.webdriver.chrome.options import Options
17+
18+
19+
# Data - Synthetic sedimentary section (Colorado Plateau stratigraphy)
20+
layers = [
21+
{"top": 0, "bottom": 18, "lithology": "sandstone", "formation": "Dakota Sandstone", "age": "Cretaceous"},
22+
{"top": 18, "bottom": 42, "lithology": "shale", "formation": "Morrison Formation", "age": "Jurassic"},
23+
{"top": 42, "bottom": 58, "lithology": "limestone", "formation": "Sundance Formation", "age": "Jurassic"},
24+
{"top": 58, "bottom": 68, "lithology": "siltstone", "formation": "Carmel Formation", "age": "Jurassic"},
25+
{"top": 68, "bottom": 100, "lithology": "sandstone", "formation": "Navajo Sandstone", "age": "Jurassic"},
26+
{"top": 100, "bottom": 118, "lithology": "conglomerate", "formation": "Kayenta Formation", "age": "Triassic"},
27+
{"top": 118, "bottom": 142, "lithology": "shale", "formation": "Chinle Formation", "age": "Triassic"},
28+
{"top": 142, "bottom": 162, "lithology": "limestone", "formation": "Kaibab Limestone", "age": "Permian"},
29+
{"top": 162, "bottom": 185, "lithology": "sandstone", "formation": "Coconino Sandstone", "age": "Permian"},
30+
{"top": 185, "bottom": 200, "lithology": "shale", "formation": "Hermit Formation", "age": "Permian"},
31+
]
32+
33+
# Lithology pattern definitions (for Highcharts pattern-fill module)
34+
lithology_config = {
35+
"sandstone": {
36+
"name": "Sandstone",
37+
"color": {
38+
"pattern": {
39+
"path": {
40+
"d": "M 3 3 a 1.5 1.5 0 1 0 0.01 0 M 9 8 a 1.5 1.5 0 1 0 0.01 0 "
41+
"M 6 1 a 1 1 0 1 0 0.01 0 M 1 10 a 1 1 0 1 0 0.01 0 "
42+
"M 11 4 a 1.2 1.2 0 1 0 0.01 0",
43+
"stroke": "#8B7355",
44+
"strokeWidth": 2.5,
45+
"fill": "#8B7355",
46+
},
47+
"width": 14,
48+
"height": 14,
49+
"backgroundColor": "#F5DEB3",
50+
}
51+
},
52+
},
53+
"shale": {
54+
"name": "Shale",
55+
"color": {
56+
"pattern": {
57+
"path": {
58+
"d": "M 0 4 L 18 4 M 2 9 L 11 9 M 13 9 L 18 9 M 0 14 L 7 14 M 9 14 L 16 14",
59+
"stroke": "#555555",
60+
"strokeWidth": 1.8,
61+
},
62+
"width": 18,
63+
"height": 18,
64+
"backgroundColor": "#B8B8B8",
65+
}
66+
},
67+
},
68+
"limestone": {
69+
"name": "Limestone",
70+
"color": {
71+
"pattern": {
72+
"path": {
73+
"d": "M 0 0 L 20 0 M 0 10 L 20 10 M 10 0 L 10 10 M 0 10 L 0 20 M 20 10 L 20 20",
74+
"stroke": "#4A7A8C",
75+
"strokeWidth": 2,
76+
},
77+
"width": 20,
78+
"height": 20,
79+
"backgroundColor": "#B8D4E3",
80+
}
81+
},
82+
},
83+
"siltstone": {
84+
"name": "Siltstone",
85+
"color": {
86+
"pattern": {
87+
"path": {
88+
"d": "M 1 4 L 6 4 M 9 4 L 13 4 M 3 9 L 8 9 M 10 9 L 15 9 M 0 14 L 5 14 M 7 14 L 11 14",
89+
"stroke": "#7A6B54",
90+
"strokeWidth": 1.8,
91+
},
92+
"width": 16,
93+
"height": 18,
94+
"backgroundColor": "#D2C4A5",
95+
}
96+
},
97+
},
98+
"conglomerate": {
99+
"name": "Conglomerate",
100+
"color": {
101+
"pattern": {
102+
"path": {
103+
"d": "M 8 8 m -5 0 a 5 5 0 1 0 10 0 a 5 5 0 1 0 -10 0 "
104+
"M 20 18 m -3.5 0 a 3.5 3.5 0 1 0 7 0 a 3.5 3.5 0 1 0 -7 0 "
105+
"M 19 5 m -2.5 0 a 2.5 2.5 0 1 0 5 0 a 2.5 2.5 0 1 0 -5 0",
106+
"stroke": "#5C3A6E",
107+
"strokeWidth": 2,
108+
"fill": "none",
109+
},
110+
"width": 26,
111+
"height": 26,
112+
"backgroundColor": "#C4A0D4",
113+
}
114+
},
115+
},
116+
}
117+
118+
# Group layers by lithology for legend
119+
lithology_groups = OrderedDict()
120+
for layer in layers:
121+
lith = layer["lithology"]
122+
if lith not in lithology_groups:
123+
lithology_groups[lith] = []
124+
lithology_groups[lith].append(layer)
125+
126+
# Age boundaries for geological period labels
127+
age_bands = [
128+
{"from": 0, "to": 18, "label": "Cretaceous", "color": "rgba(76, 175, 80, 0.06)"},
129+
{"from": 18, "to": 100, "label": "Jurassic", "color": "rgba(33, 150, 243, 0.06)"},
130+
{"from": 100, "to": 142, "label": "Triassic", "color": "rgba(255, 152, 0, 0.06)"},
131+
{"from": 142, "to": 200, "label": "Permian", "color": "rgba(156, 39, 176, 0.06)"},
132+
]
133+
134+
# Build chart configuration
135+
chart_config = {
136+
"chart": {
137+
"type": "columnrange",
138+
"width": 4800,
139+
"height": 2700,
140+
"backgroundColor": "#FAFAF8",
141+
"marginLeft": 340,
142+
"marginRight": 480,
143+
"marginTop": 200,
144+
"marginBottom": 130,
145+
"style": {"fontFamily": "'Segoe UI', Helvetica, Arial, sans-serif"},
146+
},
147+
"title": {
148+
"text": "column-stratigraphic \u00b7 highcharts \u00b7 pyplots.ai",
149+
"style": {"fontSize": "52px", "fontWeight": "bold", "color": "#2C3E50"},
150+
},
151+
"subtitle": {
152+
"text": "Synthetic Sedimentary Section \u2014 Colorado Plateau Stratigraphy",
153+
"style": {"fontSize": "34px", "color": "#7F8C8D"},
154+
},
155+
"xAxis": {"visible": False, "min": -0.25, "max": 1.3},
156+
"yAxis": {
157+
"reversed": True,
158+
"title": {
159+
"text": "Depth (m)",
160+
"style": {"fontSize": "42px", "color": "#2C3E50", "fontWeight": "600"},
161+
"margin": 30,
162+
},
163+
"labels": {"style": {"fontSize": "32px", "color": "#444444"}, "format": "{value}"},
164+
"min": 0,
165+
"max": 204,
166+
"tickInterval": 20,
167+
"gridLineWidth": 1,
168+
"gridLineColor": "rgba(0, 0, 0, 0.06)",
169+
"lineWidth": 2,
170+
"lineColor": "#CCCCCC",
171+
"plotBands": [
172+
{
173+
"from": band["from"],
174+
"to": band["to"],
175+
"color": band["color"],
176+
"label": {
177+
"text": band["label"],
178+
"align": "left",
179+
"x": -160,
180+
"verticalAlign": "middle",
181+
"rotation": 270,
182+
"style": {"fontSize": "30px", "fontWeight": "bold", "color": "#555555"},
183+
},
184+
}
185+
for band in age_bands
186+
],
187+
"plotLines": [
188+
{"value": boundary, "color": "rgba(0, 0, 0, 0.35)", "width": 3, "dashStyle": "Dash", "zIndex": 5}
189+
for boundary in [18, 100, 142]
190+
],
191+
},
192+
"legend": {
193+
"enabled": True,
194+
"layout": "vertical",
195+
"align": "right",
196+
"verticalAlign": "middle",
197+
"x": -30,
198+
"y": 0,
199+
"itemStyle": {"fontSize": "30px", "color": "#333333"},
200+
"symbolHeight": 28,
201+
"symbolWidth": 28,
202+
"itemMarginBottom": 18,
203+
"title": {"text": "Lithology", "style": {"fontSize": "34px", "fontWeight": "bold", "color": "#2C3E50"}},
204+
"backgroundColor": "rgba(255, 255, 255, 0.7)",
205+
"borderRadius": 8,
206+
"padding": 20,
207+
},
208+
"tooltip": {
209+
"style": {"fontSize": "26px"},
210+
"headerFormat": "",
211+
"pointFormat": (
212+
"<b>{point.custom.formation}</b><br/>"
213+
"{series.name}<br/>"
214+
"Depth: {point.low} \u2013 {point.high} m<br/>"
215+
"Thickness: {point.custom.thickness} m"
216+
),
217+
},
218+
"credits": {"enabled": False},
219+
"plotOptions": {"columnrange": {"grouping": False, "borderRadius": 0}},
220+
"series": [],
221+
}
222+
223+
# Build series - one per lithology type
224+
for lith, layer_list in lithology_groups.items():
225+
config = lithology_config[lith]
226+
data_points = []
227+
for layer in layer_list:
228+
thickness = layer["bottom"] - layer["top"]
229+
data_points.append(
230+
{
231+
"x": 0,
232+
"low": layer["top"],
233+
"high": layer["bottom"],
234+
"custom": {"formation": layer["formation"], "thickness": thickness},
235+
}
236+
)
237+
238+
chart_config["series"].append(
239+
{
240+
"type": "columnrange",
241+
"name": config["name"],
242+
"data": data_points,
243+
"color": config["color"],
244+
"borderColor": "#2C3E50",
245+
"borderWidth": 2.5,
246+
"pointWidth": 2200,
247+
}
248+
)
249+
250+
# Add formation name labels as a scatter series positioned to the right of the column
251+
formation_labels = []
252+
for layer in layers:
253+
mid_depth = (layer["top"] + layer["bottom"]) / 2
254+
formation_labels.append({"x": 0.68, "y": mid_depth, "name": layer["formation"]})
255+
256+
chart_config["series"].append(
257+
{
258+
"type": "scatter",
259+
"name": "Formations",
260+
"data": formation_labels,
261+
"showInLegend": False,
262+
"enableMouseTracking": False,
263+
"marker": {"enabled": False},
264+
"dataLabels": {
265+
"enabled": True,
266+
"format": "{point.name}",
267+
"align": "left",
268+
"x": 10,
269+
"y": 4,
270+
"style": {"fontSize": "30px", "fontWeight": "600", "color": "#2C3E50", "textOutline": "3px #FAFAF8"},
271+
"overflow": "allow",
272+
"crop": False,
273+
},
274+
}
275+
)
276+
277+
# Generate JavaScript - using raw JSON config because highcharts-core cannot
278+
# serialize SVG pattern path objects needed for lithology fills
279+
chart = Chart(container="container")
280+
js_config = json.dumps(chart_config, ensure_ascii=False)
281+
js_code = f"Highcharts.chart('{chart.container}', {js_config});"
282+
283+
# Load Highcharts JS modules
284+
highcharts_base = Path(__file__).resolve().parents[3] / "node_modules" / "highcharts"
285+
modules = {}
286+
for name, filename in [
287+
("highcharts", "highcharts.js"),
288+
("more", "highcharts-more.js"),
289+
("pattern", "modules/pattern-fill.js"),
290+
]:
291+
local_path = highcharts_base / filename
292+
if local_path.exists():
293+
modules[name] = local_path.read_text(encoding="utf-8")
294+
else:
295+
url = f"https://code.highcharts.com/{filename}"
296+
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
297+
with urllib.request.urlopen(req, timeout=30) as response:
298+
modules[name] = response.read().decode("utf-8")
299+
300+
html_content = f"""<!DOCTYPE html>
301+
<html>
302+
<head>
303+
<meta charset="utf-8">
304+
<script>{modules["highcharts"]}</script>
305+
<script>{modules["more"]}</script>
306+
<script>{modules["pattern"]}</script>
307+
</head>
308+
<body style="margin:0;">
309+
<div id="container" style="width: 4800px; height: 2700px;"></div>
310+
<script>{js_code}</script>
311+
</body>
312+
</html>"""
313+
314+
# Screenshot with headless Chrome
315+
with tempfile.NamedTemporaryFile(mode="w", suffix=".html", delete=False, encoding="utf-8") as f:
316+
f.write(html_content)
317+
temp_path = f.name
318+
319+
chrome_options = Options()
320+
chrome_options.add_argument("--headless")
321+
chrome_options.add_argument("--no-sandbox")
322+
chrome_options.add_argument("--disable-dev-shm-usage")
323+
chrome_options.add_argument("--disable-gpu")
324+
chrome_options.add_argument("--window-size=4800,2700")
325+
326+
driver = webdriver.Chrome(options=chrome_options)
327+
driver.get(f"file://{temp_path}")
328+
time.sleep(5)
329+
330+
container = driver.find_element("id", "container")
331+
container.screenshot("plot.png")
332+
driver.quit()
333+
334+
Path(temp_path).unlink()
335+
336+
# Save interactive HTML
337+
with open("plot.html", "w", encoding="utf-8") as f:
338+
interactive_html = f"""<!DOCTYPE html>
339+
<html>
340+
<head>
341+
<meta charset="utf-8">
342+
<script src="https://code.highcharts.com/highcharts.js"></script>
343+
<script src="https://code.highcharts.com/highcharts-more.js"></script>
344+
<script src="https://code.highcharts.com/modules/pattern-fill.js"></script>
345+
</head>
346+
<body style="margin:0;">
347+
<div id="container" style="width: 100%; height: 100vh;"></div>
348+
<script>{js_code}</script>
349+
</body>
350+
</html>"""
351+
f.write(interactive_html)

0 commit comments

Comments
 (0)