scitex 2.3.0__py3-none-any.whl → 2.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. scitex/ai/classification/reporters/reporter_utils/_Plotter.py +1 -1
  2. scitex/ai/plt/__init__.py +2 -2
  3. scitex/ai/plt/{_plot_conf_mat.py → _stx_conf_mat.py} +3 -3
  4. scitex/config/PriorityConfig.py +195 -0
  5. scitex/config/__init__.py +24 -0
  6. scitex/io/_save.py +125 -34
  7. scitex/io/_save_modules/_image.py +37 -20
  8. scitex/plt/__init__.py +470 -17
  9. scitex/plt/_subplots/_AxisWrapper.py +98 -50
  10. scitex/plt/_subplots/_AxisWrapperMixins/_MatplotlibPlotMixin.py +254 -124
  11. scitex/plt/_subplots/_AxisWrapperMixins/_SeabornMixin.py +49 -8
  12. scitex/plt/_subplots/_SubplotsWrapper.py +76 -91
  13. scitex/plt/_subplots/_export_as_csv.py +127 -58
  14. scitex/plt/_subplots/_export_as_csv_formatters/__init__.py +25 -16
  15. scitex/plt/_subplots/_export_as_csv_formatters/_format_contourf.py +54 -0
  16. scitex/plt/_subplots/_export_as_csv_formatters/_format_hexbin.py +41 -0
  17. scitex/plt/_subplots/_export_as_csv_formatters/_format_hist2d.py +41 -0
  18. scitex/plt/_subplots/_export_as_csv_formatters/_format_imshow.py +59 -47
  19. scitex/plt/_subplots/_export_as_csv_formatters/_format_matshow.py +42 -0
  20. scitex/plt/_subplots/_export_as_csv_formatters/_format_pie.py +42 -0
  21. scitex/plt/_subplots/_export_as_csv_formatters/_format_plot.py +72 -35
  22. scitex/plt/_subplots/_export_as_csv_formatters/_format_plot_box.py +1 -1
  23. scitex/plt/_subplots/_export_as_csv_formatters/_format_plot_kde.py +2 -2
  24. scitex/plt/_subplots/_export_as_csv_formatters/_format_quiver.py +53 -0
  25. scitex/plt/_subplots/_export_as_csv_formatters/_format_stem.py +42 -0
  26. scitex/plt/_subplots/_export_as_csv_formatters/_format_step.py +42 -0
  27. scitex/plt/_subplots/_export_as_csv_formatters/_format_streamplot.py +48 -0
  28. scitex/plt/_subplots/_export_as_csv_formatters/{_format_plot_conf_mat.py → _format_stx_conf_mat.py} +2 -2
  29. scitex/plt/_subplots/_export_as_csv_formatters/{_format_plot_ecdf.py → _format_stx_ecdf.py} +2 -2
  30. scitex/plt/_subplots/_export_as_csv_formatters/{_format_plot_fillv.py → _format_stx_fillv.py} +2 -2
  31. scitex/plt/_subplots/_export_as_csv_formatters/{_format_plot_heatmap.py → _format_stx_heatmap.py} +2 -2
  32. scitex/plt/_subplots/_export_as_csv_formatters/{_format_plot_image.py → _format_stx_image.py} +2 -2
  33. scitex/plt/_subplots/_export_as_csv_formatters/{_format_plot_joyplot.py → _format_stx_joyplot.py} +2 -2
  34. scitex/plt/_subplots/_export_as_csv_formatters/{_format_plot_line.py → _format_stx_line.py} +3 -3
  35. scitex/plt/_subplots/_export_as_csv_formatters/{_format_plot_mean_ci.py → _format_stx_mean_ci.py} +2 -2
  36. scitex/plt/_subplots/_export_as_csv_formatters/{_format_plot_mean_std.py → _format_stx_mean_std.py} +2 -2
  37. scitex/plt/_subplots/_export_as_csv_formatters/{_format_plot_median_iqr.py → _format_stx_median_iqr.py} +2 -2
  38. scitex/plt/_subplots/_export_as_csv_formatters/{_format_plot_raster.py → _format_stx_raster.py} +2 -2
  39. scitex/plt/_subplots/_export_as_csv_formatters/{_format_plot_rectangle.py → _format_stx_rectangle.py} +1 -1
  40. scitex/plt/_subplots/_export_as_csv_formatters/{_format_plot_scatter_hist.py → _format_stx_scatter_hist.py} +2 -2
  41. scitex/plt/_subplots/_export_as_csv_formatters/{_format_plot_shaded_line.py → _format_stx_shaded_line.py} +2 -2
  42. scitex/plt/_subplots/_export_as_csv_formatters/{_format_plot_violin.py → _format_stx_violin.py} +2 -2
  43. scitex/plt/_subplots/_export_as_csv_formatters/verify_formatters.py +23 -23
  44. scitex/plt/ax/__init__.py +16 -15
  45. scitex/plt/ax/_plot/__init__.py +30 -30
  46. scitex/plt/ax/_plot/_add_fitted_line.py +65 -11
  47. scitex/plt/ax/_plot/_plot_statistical_shaded_line.py +104 -76
  48. scitex/plt/ax/_plot/{_plot_conf_mat.py → _stx_conf_mat.py} +10 -10
  49. scitex/plt/ax/_plot/_stx_ecdf.py +109 -0
  50. scitex/plt/ax/_plot/{_plot_fillv.py → _stx_fillv.py} +7 -7
  51. scitex/plt/ax/_plot/_stx_heatmap.py +366 -0
  52. scitex/plt/ax/_plot/{_plot_image.py → _stx_image.py} +1 -1
  53. scitex/plt/ax/_plot/_stx_joyplot.py +113 -0
  54. scitex/plt/ax/_plot/{_plot_raster.py → _stx_raster.py} +37 -25
  55. scitex/plt/ax/_plot/{_plot_rectangle.py → _stx_rectangle.py} +10 -9
  56. scitex/plt/ax/_plot/{_plot_scatter_hist.py → _stx_scatter_hist.py} +1 -1
  57. scitex/plt/ax/_plot/_stx_shaded_line.py +215 -0
  58. scitex/plt/ax/_plot/{_plot_violin.py → _stx_violin.py} +13 -6
  59. scitex/plt/ax/_style/__init__.py +3 -0
  60. scitex/plt/ax/_style/_style_barplot.py +13 -2
  61. scitex/plt/ax/_style/_style_boxplot.py +78 -32
  62. scitex/plt/ax/_style/_style_errorbar.py +17 -3
  63. scitex/plt/ax/_style/_style_scatter.py +17 -3
  64. scitex/plt/ax/_style/_style_violinplot.py +109 -0
  65. scitex/plt/color/_vizualize_colors.py +3 -3
  66. scitex/plt/styles/SCITEX_STYLE.yaml +104 -0
  67. scitex/plt/styles/__init__.py +57 -0
  68. scitex/plt/styles/_plot_defaults.py +209 -0
  69. scitex/plt/styles/_plot_postprocess.py +518 -0
  70. scitex/plt/styles/_style_loader.py +268 -0
  71. scitex/plt/styles/presets.py +208 -0
  72. scitex/plt/utils/_collect_figure_metadata.py +160 -18
  73. scitex/plt/utils/_colorbar.py +72 -10
  74. scitex/plt/utils/_configure_mpl.py +108 -52
  75. scitex/plt/utils/_crop.py +21 -7
  76. scitex/plt/utils/_figure_mm.py +21 -7
  77. scitex/stats/__init__.py +13 -1
  78. scitex/stats/_schema.py +578 -0
  79. scitex/stats/tests/__init__.py +13 -0
  80. scitex/stats/tests/correlation/__init__.py +13 -0
  81. scitex/stats/tests/correlation/_test_pearson.py +262 -0
  82. scitex/vis/__init__.py +6 -0
  83. scitex/vis/editor/__init__.py +23 -0
  84. scitex/vis/editor/_defaults.py +205 -0
  85. scitex/vis/editor/_edit.py +342 -0
  86. scitex/vis/editor/_mpl_editor.py +231 -0
  87. scitex/vis/editor/_tkinter_editor.py +466 -0
  88. scitex/vis/editor/_web_editor.py +1440 -0
  89. scitex/vis/model/plot_types.py +15 -15
  90. {scitex-2.3.0.dist-info → scitex-2.4.0.dist-info}/METADATA +2 -1
  91. {scitex-2.3.0.dist-info → scitex-2.4.0.dist-info}/RECORD +94 -67
  92. {scitex-2.3.0.dist-info → scitex-2.4.0.dist-info}/WHEEL +1 -1
  93. scitex/plt/ax/_plot/_plot_ecdf.py +0 -84
  94. scitex/plt/ax/_plot/_plot_heatmap.py +0 -277
  95. scitex/plt/ax/_plot/_plot_joyplot.py +0 -77
  96. scitex/plt/ax/_plot/_plot_shaded_line.py +0 -142
  97. scitex/plt/presets.py +0 -224
  98. {scitex-2.3.0.dist-info → scitex-2.4.0.dist-info}/entry_points.txt +0 -0
  99. {scitex-2.3.0.dist-info → scitex-2.4.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,518 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ # Timestamp: "2025-12-01 10:00:00 (ywatanabe)"
4
+ # File: /home/ywatanabe/proj/scitex-code/src/scitex/plt/styles/_plot_postprocess.py
5
+
6
+ """Post-processing styling for plot methods.
7
+
8
+ This module centralizes all styling applied AFTER matplotlib methods
9
+ are called. Each function modifies the plot result or axes in-place.
10
+
11
+ All default values are loaded from SCITEX_STYLE.yaml via presets.py.
12
+ """
13
+
14
+ import numpy as np
15
+ from matplotlib.ticker import MaxNLocator, FixedLocator
16
+ from matplotlib.category import StrCategoryConverter, UnitData
17
+
18
+ from scitex.plt.utils import mm_to_pt
19
+ from scitex.plt.styles.presets import SCITEX_STYLE
20
+
21
+
22
+ # ============================================================================
23
+ # Constants (loaded from centralized SCITEX_STYLE.yaml)
24
+ # ============================================================================
25
+ DEFAULT_LINE_WIDTH_MM = SCITEX_STYLE.get("trace_thickness_mm", 0.2)
26
+ DEFAULT_MARKER_SIZE_MM = SCITEX_STYLE.get("marker_size_mm", 0.8)
27
+ DEFAULT_N_TICKS = SCITEX_STYLE.get("n_ticks", 4) - 1 # nbins = n_ticks - 1
28
+ SPINE_ZORDER = 1000
29
+ CAP_WIDTH_RATIO = 1 / 3 # 33% of bar/box width
30
+
31
+
32
+ # ============================================================================
33
+ # Main post-processing function
34
+ # ============================================================================
35
+ def apply_plot_postprocess(method_name, result, ax, kwargs, args=None):
36
+ """Apply post-processing styling after matplotlib method call.
37
+
38
+ Args:
39
+ method_name: Name of the matplotlib method that was called
40
+ result: Return value from the matplotlib method
41
+ ax: The matplotlib axes
42
+ kwargs: Original kwargs passed to the method
43
+ args: Original positional args passed to the method (needed for violinplot)
44
+
45
+ Returns:
46
+ The result (possibly modified)
47
+ """
48
+ # Always ensure spines are on top
49
+ _ensure_spines_on_top(ax)
50
+
51
+ # Apply tick locator for numerical axes
52
+ _apply_tick_locator(ax)
53
+
54
+ # Method-specific post-processing
55
+ if method_name == 'pie' and result is not None:
56
+ _postprocess_pie(result)
57
+ elif method_name == 'stem' and result is not None:
58
+ _postprocess_stem(result)
59
+ elif method_name == 'violinplot' and result is not None:
60
+ _postprocess_violin(result, ax, kwargs, args)
61
+ elif method_name == 'boxplot' and result is not None:
62
+ _postprocess_boxplot(result, ax)
63
+ elif method_name == 'scatter' and result is not None:
64
+ _postprocess_scatter(result, kwargs)
65
+ elif method_name == 'bar' and result is not None:
66
+ _postprocess_bar(result, ax, kwargs)
67
+ elif method_name == 'barh' and result is not None:
68
+ _postprocess_barh(result, ax, kwargs)
69
+ elif method_name == 'errorbar' and result is not None:
70
+ _postprocess_errorbar(result)
71
+ elif method_name == 'hist' and result is not None:
72
+ _postprocess_hist(result, ax)
73
+ elif method_name == 'fill_between' and result is not None:
74
+ _postprocess_fill_between(result, kwargs)
75
+
76
+ return result
77
+
78
+
79
+ # ============================================================================
80
+ # General post-processing
81
+ # ============================================================================
82
+ def _ensure_spines_on_top(ax):
83
+ """Ensure axes spines are always drawn in front of plot elements."""
84
+ try:
85
+ ax.set_axisbelow(False)
86
+
87
+ # Set very high z-order for spines
88
+ for spine in ax.spines.values():
89
+ spine.set_zorder(SPINE_ZORDER)
90
+
91
+ # Set z-order for tick marks
92
+ ax.tick_params(zorder=SPINE_ZORDER)
93
+
94
+ # Ensure plot patches have lower z-order than spines
95
+ # But preserve intentionally set z-orders (e.g., boxplot in violin)
96
+ for patch in ax.patches:
97
+ current_z = patch.get_zorder()
98
+ # Only lower z-order if it's >= SPINE_ZORDER or is at matplotlib default (1)
99
+ if current_z >= SPINE_ZORDER:
100
+ patch.set_zorder(current_z - SPINE_ZORDER)
101
+ elif current_z == 1:
102
+ # Default matplotlib z-order, lower it
103
+ patch.set_zorder(0.5)
104
+ # Otherwise, preserve the intentionally set z-order
105
+
106
+ # Set axes patch behind everything
107
+ ax.patch.set_zorder(-1)
108
+ except Exception:
109
+ pass
110
+
111
+
112
+ def _apply_tick_locator(ax):
113
+ """Apply MaxNLocator only to numerical (non-categorical) axes.
114
+
115
+ Target: 3-4 ticks per axis for clean publication figures.
116
+ MaxNLocator's nbins=3 gives approximately 3-4 tick marks.
117
+ min_n_ticks=3 ensures at least 3 ticks (never 2).
118
+ """
119
+ try:
120
+ def is_categorical_axis(axis):
121
+ if isinstance(axis.converter, StrCategoryConverter):
122
+ return True
123
+ if hasattr(axis, 'units') and isinstance(axis.units, UnitData):
124
+ return True
125
+ if isinstance(axis.get_major_locator(), FixedLocator):
126
+ return True
127
+ return False
128
+
129
+ if not is_categorical_axis(ax.xaxis):
130
+ ax.xaxis.set_major_locator(
131
+ MaxNLocator(nbins=DEFAULT_N_TICKS, min_n_ticks=3, integer=False, prune=None)
132
+ )
133
+
134
+ if not is_categorical_axis(ax.yaxis):
135
+ ax.yaxis.set_major_locator(
136
+ MaxNLocator(nbins=DEFAULT_N_TICKS, min_n_ticks=3, integer=False, prune=None)
137
+ )
138
+ except Exception:
139
+ pass
140
+
141
+
142
+ # ============================================================================
143
+ # Method-specific post-processing
144
+ # ============================================================================
145
+ def _postprocess_pie(result):
146
+ """Apply styling for pie charts."""
147
+ # pie returns (wedges, texts, autotexts) when autopct is used
148
+ if len(result) >= 3:
149
+ autotexts = result[2]
150
+ for autotext in autotexts:
151
+ autotext.set_fontsize(6) # 6pt for inline percentages
152
+
153
+
154
+ def _postprocess_stem(result):
155
+ """Apply styling for stem plots."""
156
+ baseline = result.baseline
157
+ if baseline is not None:
158
+ baseline.set_color('black')
159
+ baseline.set_linestyle('--')
160
+
161
+
162
+ def _postprocess_errorbar(result):
163
+ """Apply styling for errorbar plots.
164
+
165
+ Simplifies the legend to show only a line (no caps/bars).
166
+ """
167
+ import matplotlib.legend as mlegend
168
+ from matplotlib.container import ErrorbarContainer
169
+ from matplotlib.legend_handler import HandlerErrorbar, HandlerLine2D
170
+
171
+ # Custom handler that shows only a simple line for errorbar
172
+ class SimpleLineHandler(HandlerErrorbar):
173
+ def create_artists(self, legend, orig_handle, xdescent, ydescent,
174
+ width, height, fontsize, trans):
175
+ # Use HandlerLine2D to create just a line
176
+ line_handler = HandlerLine2D()
177
+ # Get the data line from the ErrorbarContainer
178
+ data_line = orig_handle[0]
179
+ if data_line is not None:
180
+ return line_handler.create_artists(
181
+ legend, data_line, xdescent, ydescent,
182
+ width, height, fontsize, trans
183
+ )
184
+ return []
185
+
186
+ # Register the handler globally for ErrorbarContainer
187
+ mlegend.Legend.update_default_handler_map({ErrorbarContainer: SimpleLineHandler()})
188
+
189
+
190
+ def _postprocess_violin(result, ax, kwargs, args):
191
+ """Apply styling for violin plots with optional boxplot overlay."""
192
+ # Get scitex palette for coloring
193
+ from scitex.plt.color._PARAMS import HEX
194
+ palette = [HEX['blue'], HEX['red'], HEX['green'], HEX['yellow'],
195
+ HEX['purple'], HEX['orange'], HEX['lightblue'], HEX['pink']]
196
+
197
+ if 'bodies' in result:
198
+ for i, body in enumerate(result['bodies']):
199
+ body.set_facecolor(palette[i % len(palette)])
200
+ body.set_edgecolor('black')
201
+ body.set_linewidth(mm_to_pt(DEFAULT_LINE_WIDTH_MM))
202
+ body.set_alpha(1.0)
203
+
204
+ # Add boxplot overlay by default (disable with boxplot=False)
205
+ add_boxplot = kwargs.pop('boxplot', True)
206
+ if add_boxplot and args:
207
+ try:
208
+ # Get data from first positional argument
209
+ data = args[0]
210
+ # Get positions if specified, otherwise use default
211
+ positions = kwargs.get('positions', None)
212
+ if positions is None:
213
+ positions = range(1, len(data) + 1)
214
+
215
+ # Calculate boxplot width dynamically from violin width
216
+ # Get violin width from kwargs or use matplotlib default (0.5)
217
+ violin_widths = kwargs.get('widths', 0.5)
218
+ if hasattr(violin_widths, '__iter__'):
219
+ violin_widths = violin_widths[0] if len(violin_widths) > 0 else 0.5
220
+ # Boxplot width = 20% of violin width
221
+ boxplot_widths = violin_widths * 0.2
222
+
223
+ # Draw boxplot overlay with styling
224
+ line_width = mm_to_pt(DEFAULT_LINE_WIDTH_MM)
225
+ marker_size = mm_to_pt(DEFAULT_MARKER_SIZE_MM)
226
+
227
+ # Call matplotlib's boxplot directly to avoid recursive post-processing
228
+ # which would override our gray styling with the default blue
229
+ if hasattr(ax, '_axes_mpl'):
230
+ mpl_ax = ax._axes_mpl
231
+ else:
232
+ mpl_ax = ax
233
+ bp = mpl_ax.boxplot(
234
+ data,
235
+ positions=list(positions),
236
+ widths=boxplot_widths,
237
+ patch_artist=True,
238
+ manage_ticks=False, # Don't modify existing ticks
239
+ )
240
+
241
+ # Style the boxplot: scitex gray fill with black edges for visibility
242
+ # Set high z-order so boxplot appears on top of violin bodies
243
+ from scitex.plt.color._PARAMS import HEX
244
+ boxplot_zorder = 10
245
+ for box in bp.get('boxes', []):
246
+ box.set_facecolor(HEX['gray']) # Scitex gray fill
247
+ box.set_edgecolor('black')
248
+ box.set_alpha(1.0)
249
+ box.set_linewidth(line_width)
250
+ box.set_zorder(boxplot_zorder)
251
+ for median in bp.get('medians', []):
252
+ median.set_color('black') # Black median line
253
+ median.set_linewidth(line_width) # 0.2mm thickness
254
+ median.set_zorder(boxplot_zorder + 1)
255
+ for whisker in bp.get('whiskers', []):
256
+ whisker.set_color('black')
257
+ whisker.set_linewidth(line_width)
258
+ whisker.set_zorder(boxplot_zorder)
259
+ for cap in bp.get('caps', []):
260
+ cap.set_color('black')
261
+ cap.set_linewidth(line_width)
262
+ cap.set_zorder(boxplot_zorder)
263
+ for flier in bp.get('fliers', []):
264
+ flier.set_markerfacecolor('none') # No fill (open circles)
265
+ flier.set_markeredgecolor('black')
266
+ flier.set_markersize(marker_size) # 0.8mm
267
+ flier.set_markeredgewidth(line_width) # 0.2mm
268
+ flier.set_zorder(boxplot_zorder + 2)
269
+ except Exception:
270
+ pass # Silently continue if boxplot overlay fails
271
+
272
+
273
+ def _postprocess_boxplot(result, ax):
274
+ """Apply styling for boxplots (standalone, not violin overlay)."""
275
+ # Use the centralized style_boxplot function for consistent styling
276
+ from scitex.plt.ax import style_boxplot
277
+ style_boxplot(result)
278
+
279
+ # Cap width: 33% of box width
280
+ if 'caps' in result and 'boxes' in result and len(result['boxes']) > 0:
281
+ try:
282
+ cap_width_pts = _calculate_cap_width_from_box(result['boxes'][0], ax)
283
+ for cap in result['caps']:
284
+ cap.set_markersize(cap_width_pts)
285
+ except Exception:
286
+ pass
287
+
288
+
289
+ def _postprocess_scatter(result, kwargs):
290
+ """Apply styling for scatter plots."""
291
+ # Apply default 0.8mm marker size if 's' not specified
292
+ if 's' not in kwargs:
293
+ size_pt = mm_to_pt(DEFAULT_MARKER_SIZE_MM)
294
+ marker_area = size_pt ** 2
295
+ result.set_sizes([marker_area])
296
+
297
+
298
+ def _postprocess_hist(result, ax):
299
+ """Apply styling for histogram plots.
300
+
301
+ Ensures histogram bars have proper edge color and alpha for visibility.
302
+ """
303
+ line_width = mm_to_pt(DEFAULT_LINE_WIDTH_MM)
304
+
305
+ # result is (n, bins, patches) tuple
306
+ if len(result) >= 3:
307
+ patches = result[2]
308
+ # Handle both single histogram and stacked histograms
309
+ if hasattr(patches, '__iter__'):
310
+ for patch_group in patches:
311
+ if hasattr(patch_group, '__iter__'):
312
+ for patch in patch_group:
313
+ patch.set_edgecolor('black')
314
+ patch.set_linewidth(line_width)
315
+ # Ensure alpha is at least 0.7 for visibility
316
+ if patch.get_alpha() is None or patch.get_alpha() < 0.7:
317
+ patch.set_alpha(1.0)
318
+ else:
319
+ # Single patch
320
+ patch_group.set_edgecolor('black')
321
+ patch_group.set_linewidth(line_width)
322
+ if patch_group.get_alpha() is None or patch_group.get_alpha() < 0.7:
323
+ patch_group.set_alpha(1.0)
324
+
325
+
326
+ def _postprocess_fill_between(result, kwargs):
327
+ """Apply styling for fill_between plots.
328
+
329
+ Ensures shaded regions have proper alpha for visibility.
330
+ """
331
+ # result is a PolyCollection
332
+ if result is not None:
333
+ # Set edge color to match face color or black
334
+ line_width = mm_to_pt(DEFAULT_LINE_WIDTH_MM)
335
+
336
+ # Only set edge if not already specified
337
+ if 'edgecolor' not in kwargs and 'ec' not in kwargs:
338
+ result.set_edgecolor('none')
339
+
340
+ # Ensure alpha is reasonable (default 0.3 is common for fill_between)
341
+ if 'alpha' not in kwargs:
342
+ result.set_alpha(0.3)
343
+
344
+
345
+ def _postprocess_bar(result, ax, kwargs):
346
+ """Apply styling for bar plots with colors and error bars."""
347
+ # Get scitex palette for coloring (only if color not explicitly set)
348
+ if 'color' not in kwargs and 'c' not in kwargs:
349
+ from scitex.plt.color._PARAMS import HEX
350
+ palette = [HEX['blue'], HEX['red'], HEX['green'], HEX['yellow'],
351
+ HEX['purple'], HEX['orange'], HEX['lightblue'], HEX['pink']]
352
+
353
+ line_width = mm_to_pt(DEFAULT_LINE_WIDTH_MM)
354
+ for i, patch in enumerate(result.patches):
355
+ patch.set_facecolor(palette[i % len(palette)])
356
+ patch.set_edgecolor('black')
357
+ patch.set_linewidth(line_width)
358
+
359
+ if 'yerr' not in kwargs or kwargs['yerr'] is None:
360
+ return
361
+
362
+ try:
363
+ errorbar = result.errorbar
364
+ if errorbar is None:
365
+ return
366
+
367
+ lines = errorbar.lines
368
+ if not lines or len(lines) < 3:
369
+ return
370
+
371
+ caplines = lines[1]
372
+ if caplines and len(caplines) >= 2:
373
+ # Hide lower caps (one-sided error bars)
374
+ caplines[0].set_visible(False)
375
+
376
+ # Adjust cap width to 33% of bar width
377
+ if len(result.patches) > 0:
378
+ cap_width_pts = _calculate_cap_width_from_bar(
379
+ result.patches[0], ax, 'width'
380
+ )
381
+ for cap in caplines[1:]:
382
+ cap.set_markersize(cap_width_pts)
383
+
384
+ # Make error bar lines one-sided
385
+ barlinecols = lines[2]
386
+ _make_errorbar_one_sided(barlinecols, 'vertical')
387
+ except Exception:
388
+ pass
389
+
390
+
391
+ def _postprocess_barh(result, ax, kwargs):
392
+ """Apply styling for horizontal bar plots with colors and error bars."""
393
+ # Get scitex palette for coloring (only if color not explicitly set)
394
+ if 'color' not in kwargs and 'c' not in kwargs:
395
+ from scitex.plt.color._PARAMS import HEX
396
+ palette = [HEX['blue'], HEX['red'], HEX['green'], HEX['yellow'],
397
+ HEX['purple'], HEX['orange'], HEX['lightblue'], HEX['pink']]
398
+
399
+ line_width = mm_to_pt(DEFAULT_LINE_WIDTH_MM)
400
+ for i, patch in enumerate(result.patches):
401
+ patch.set_facecolor(palette[i % len(palette)])
402
+ patch.set_edgecolor('black')
403
+ patch.set_linewidth(line_width)
404
+
405
+ if 'xerr' not in kwargs or kwargs['xerr'] is None:
406
+ return
407
+
408
+ try:
409
+ errorbar = result.errorbar
410
+ if errorbar is None:
411
+ return
412
+
413
+ lines = errorbar.lines
414
+ if not lines or len(lines) < 3:
415
+ return
416
+
417
+ caplines = lines[1]
418
+ if caplines and len(caplines) >= 2:
419
+ # Hide left caps (one-sided error bars)
420
+ caplines[0].set_visible(False)
421
+
422
+ # Adjust cap width to 33% of bar height
423
+ if len(result.patches) > 0:
424
+ cap_width_pts = _calculate_cap_width_from_bar(
425
+ result.patches[0], ax, 'height'
426
+ )
427
+ for cap in caplines[1:]:
428
+ cap.set_markersize(cap_width_pts)
429
+
430
+ # Make error bar lines one-sided
431
+ barlinecols = lines[2]
432
+ _make_errorbar_one_sided(barlinecols, 'horizontal')
433
+ except Exception:
434
+ pass
435
+
436
+
437
+ # ============================================================================
438
+ # Helper functions
439
+ # ============================================================================
440
+ def _calculate_cap_width_from_box(box, ax):
441
+ """Calculate cap width as 33% of box width in points."""
442
+ # Get box width from path
443
+ if hasattr(box, 'get_path'):
444
+ path = box.get_path()
445
+ vertices = path.vertices
446
+ x_coords = vertices[:, 0]
447
+ box_width_data = x_coords.max() - x_coords.min()
448
+ elif hasattr(box, 'get_xdata'):
449
+ x_data = box.get_xdata()
450
+ box_width_data = max(x_data) - min(x_data)
451
+ else:
452
+ box_width_data = 0.5 # Default
453
+
454
+ return _data_width_to_points(box_width_data, ax, 'x') * CAP_WIDTH_RATIO
455
+
456
+
457
+ def _calculate_cap_width_from_bar(patch, ax, dimension):
458
+ """Calculate cap width as 33% of bar width/height in points."""
459
+ if dimension == 'width':
460
+ bar_size = patch.get_width()
461
+ return _data_width_to_points(bar_size, ax, 'x') * CAP_WIDTH_RATIO
462
+ else: # height
463
+ bar_size = patch.get_height()
464
+ return _data_width_to_points(bar_size, ax, 'y') * CAP_WIDTH_RATIO
465
+
466
+
467
+ def _data_width_to_points(data_size, ax, axis='x'):
468
+ """Convert a data-space size to points."""
469
+ fig = ax.get_figure()
470
+ bbox = ax.get_position()
471
+
472
+ if axis == 'x':
473
+ ax_size_inches = bbox.width * fig.get_figwidth()
474
+ lim = ax.get_xlim()
475
+ else:
476
+ ax_size_inches = bbox.height * fig.get_figheight()
477
+ lim = ax.get_ylim()
478
+
479
+ data_range = lim[1] - lim[0]
480
+ size_inches = (data_size / data_range) * ax_size_inches
481
+ return size_inches * 72 # 72 points per inch
482
+
483
+
484
+ def _make_errorbar_one_sided(barlinecols, direction):
485
+ """Make error bar line segments one-sided (outward only)."""
486
+ if not barlinecols or len(barlinecols) == 0:
487
+ return
488
+
489
+ for lc in barlinecols:
490
+ if not hasattr(lc, 'get_segments'):
491
+ continue
492
+
493
+ segs = lc.get_segments()
494
+ new_segs = []
495
+ for seg in segs:
496
+ if len(seg) < 2:
497
+ continue
498
+
499
+ if direction == 'vertical':
500
+ # Keep upper half
501
+ bottom_y = min(seg[0][1], seg[1][1])
502
+ top_y = max(seg[0][1], seg[1][1])
503
+ mid_y = (bottom_y + top_y) / 2
504
+ new_seg = np.array([[seg[0][0], mid_y], [seg[0][0], top_y]])
505
+ else: # horizontal
506
+ # Keep right half
507
+ left_x = min(seg[0][0], seg[1][0])
508
+ right_x = max(seg[0][0], seg[1][0])
509
+ mid_x = (left_x + right_x) / 2
510
+ new_seg = np.array([[mid_x, seg[0][1]], [right_x, seg[0][1]]])
511
+
512
+ new_segs.append(new_seg)
513
+
514
+ if new_segs:
515
+ lc.set_segments(new_segs)
516
+
517
+
518
+ # EOF