majoplot 0.1.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.
@@ -0,0 +1,337 @@
1
+ from __future__ import annotations
2
+
3
+ from itertools import cycle
4
+ from typing import Iterable, Optional, Sequence, Tuple
5
+
6
+ import matplotlib.figure as mfig
7
+ import matplotlib.pyplot as plt
8
+
9
+ from ...domain.base import *
10
+ from ...domain.muti_axes_spec import *
11
+
12
+
13
+ # Inset axes should look slightly lighter/smaller than the main axes.
14
+ # Only scale what the user explicitly requested: marker, linewidth, axis-title fontsize.
15
+ INSET_STYLE_SCALE = 0.75
16
+
17
+
18
+ def _nonempty_cycle(values: Sequence, fallback: Sequence) -> Iterable:
19
+ """Return an infinite cycle; if values is empty, cycle fallback instead."""
20
+ if values:
21
+ return cycle(values)
22
+ return cycle(fallback)
23
+
24
+
25
+ def _safe_arrowstyle(value: object) -> str:
26
+ """ArrowSpec.arrowstyle may accidentally be a 1-tuple due to a trailing comma."""
27
+ if isinstance(value, tuple) and len(value) == 1:
28
+ return str(value[0])
29
+ return str(value)
30
+
31
+
32
+ def _apply_ticks(ax: plt.Axes, tick: TickSpec, labelsize: float) -> None:
33
+ """Apply TickSpec to an Axes."""
34
+ ax.tick_params(
35
+ axis=tick.axis,
36
+ direction=tick.direction,
37
+ length=tick.length,
38
+ width=tick.width,
39
+ top=tick.top,
40
+ left=tick.left,
41
+ labelsize=labelsize,
42
+ )
43
+
44
+
45
+ def _apply_grid(ax: plt.Axes, grid: GridSpec, which: str) -> None:
46
+ """Apply GridSpec to an Axes."""
47
+ ax.grid(
48
+ True,
49
+ which=which,
50
+ linestyle=grid.linestyle,
51
+ linewidth=grid.linewidth,
52
+ color=grid.color,
53
+ )
54
+
55
+
56
+ def _draw_axes_legend(
57
+ ax: plt.Axes,
58
+ handles: list,
59
+ labels: list,
60
+ legend: Optional[LegendSpec],
61
+ ) -> None:
62
+ """Draw a legend on an Axes using an optional LegendSpec."""
63
+ if not handles:
64
+ return
65
+
66
+ if legend is None:
67
+ ax.legend(handles, labels)
68
+ return
69
+
70
+ ax.legend(
71
+ handles,
72
+ labels,
73
+ loc=legend.loc,
74
+ bbox_to_anchor=(legend.anchor_x, legend.anchor_y),
75
+ fontsize=legend.fontsize,
76
+ frameon=legend.frameon,
77
+ )
78
+
79
+
80
+ def _draw_figure_legend(
81
+ matfig: mfig.Figure,
82
+ handles: list,
83
+ labels: list,
84
+ legend: Optional[LegendSpec],
85
+ ) -> None:
86
+ """Draw a figure-level legend using an optional LegendSpec."""
87
+ if not handles:
88
+ return
89
+
90
+ if legend is None:
91
+ matfig.legend(handles, labels)
92
+ return
93
+
94
+ matfig.legend(
95
+ handles,
96
+ labels,
97
+ loc=legend.loc,
98
+ bbox_to_anchor=(legend.anchor_x, legend.anchor_y),
99
+ fontsize=legend.fontsize,
100
+ frameon=legend.frameon,
101
+ )
102
+
103
+
104
+ def _maybe_set_log_scale(ax: plt.Axes, axes_obj: Axes) -> None:
105
+ """Enable log scale when limits are positive and span exceeds thresholds."""
106
+ xl = axes_obj.xlim[0]
107
+ xr = axes_obj.xlim[1]
108
+ yl = axes_obj.ylim[0]
109
+ yr = axes_obj.ylim[1]
110
+ x_span_min = axes_obj.spec.x_log_scale_min_span
111
+ y_span_min = axes_obj.spec.y_log_scale_min_span
112
+
113
+ if xl > 0 and xr > 0 and (xr / xl) >= x_span_min:
114
+ ax.set_xscale("log")
115
+
116
+ if yl > 0 and yr > 0 and (yr / yl) >= y_span_min:
117
+ ax.set_yscale("log")
118
+
119
+
120
+ def plot(myfig: Figure) -> mfig.Figure:
121
+ """
122
+ Render a FigureSpec-driven Figure into a matplotlib Figure (OO-style).
123
+
124
+ Guarantees:
125
+ - All drawing-related Specs in base.py are respected
126
+ - Global style cycles are shared across all Axes in the same Figure
127
+ - If there are more series than cycle elements, cycles repeat automatically
128
+ - Returns matplotlib.figure.Figure without saving
129
+ """
130
+ # Ensure every Data has an overall label (used for legends)
131
+ fig_spec: FigureSpec = myfig.spec
132
+ global_fontsize: float = getattr(fig_spec, "global_fontsize", 8.0)
133
+
134
+ # Global style cycles (shared across the entire Figure)
135
+ ls_cycle = _nonempty_cycle(getattr(fig_spec, "linestyle_cycle", ("-",)), ("-",))
136
+ lc_cycle = _nonempty_cycle(getattr(fig_spec, "linecolor_cycle", ("black",)), ("black",))
137
+ mk_cycle = _nonempty_cycle(getattr(fig_spec, "linemarker_cycle", ("o",)), ("o",))
138
+ al_cycle = _nonempty_cycle(getattr(fig_spec, "alpa_cycle", (1.0,)), (1.0,))
139
+
140
+ # ---------- Layout dispatch ----------
141
+ muti_spec = myfig.muti_axes_spec
142
+ mpl_axes: list[plt.Axes] = []
143
+
144
+ if muti_spec is None:
145
+ matfig, ax = plt.subplots(figsize=fig_spec.figsize, dpi=fig_spec.dpi)
146
+ mpl_axes = [ax]
147
+
148
+ elif isinstance(muti_spec, InsertAxesSpec):
149
+ matfig = plt.figure(figsize=fig_spec.figsize, dpi=fig_spec.dpi)
150
+ main_ax = matfig.add_subplot(111)
151
+ inset_ax = main_ax.inset_axes([muti_spec.x, muti_spec.y, muti_spec.width, muti_spec.height])
152
+ mpl_axes = [main_ax, inset_ax]
153
+
154
+ elif isinstance(muti_spec, DualYAxesSpec):
155
+ matfig = plt.figure(figsize=fig_spec.figsize, dpi=fig_spec.dpi)
156
+ left_ax = matfig.add_subplot(111)
157
+ right_ax = left_ax.twinx()
158
+ mpl_axes = [left_ax, right_ax]
159
+
160
+ elif isinstance(muti_spec, StackAxesSpec):
161
+ matfig, axes = plt.subplots(
162
+ muti_spec.nrows,
163
+ muti_spec.ncols,
164
+ figsize=fig_spec.figsize,
165
+ dpi=fig_spec.dpi,
166
+ )
167
+ mpl_axes = [axes] if isinstance(axes, plt.Axes) else list(axes.flatten())
168
+
169
+ else:
170
+ raise TypeError(f"Unsupported MutiAxesSpec: {type(muti_spec)}")
171
+
172
+ # Figure-level properties
173
+ matfig.set_facecolor(fig_spec.facecolor)
174
+ if getattr(fig_spec, "title", None):
175
+ matfig.suptitle(fig_spec.title, fontsize=global_fontsize)
176
+
177
+ per_ax_info: list[Tuple[plt.Axes, list, list, AxesSpec]] = []
178
+
179
+ # ---------- Draw each Axes ----------
180
+ for ax_idx, (ax, axes_obj) in enumerate(zip(mpl_axes, myfig.axes_pool)):
181
+ axes_spec: AxesSpec = axes_obj.spec
182
+
183
+ # for stack, every axes has its own cycle
184
+ if isinstance(myfig.muti_axes_spec, StackAxesSpec):
185
+ ls_cycle = _nonempty_cycle(getattr(fig_spec, "linestyle_cycle", ("-",)), ("-",))
186
+ lc_cycle = _nonempty_cycle(getattr(fig_spec, "linecolor_cycle", ("black",)), ("black",))
187
+ mk_cycle = _nonempty_cycle(getattr(fig_spec, "linemarker_cycle", ("o",)), ("o",))
188
+ al_cycle = _nonempty_cycle(getattr(fig_spec, "alpa_cycle", (1.0,)), (1.0,))
189
+
190
+
191
+ # Determine if this axes is an inset axes that should be slightly scaled down
192
+ is_inset = isinstance(muti_spec, InsertAxesSpec) and (ax_idx == 1)
193
+ style_scale = INSET_STYLE_SCALE if is_inset else 1.0
194
+
195
+ # Apply per-data plotting
196
+ for data in axes_obj.data_pool:
197
+ if getattr(data, "ignore", False):
198
+ continue
199
+
200
+ points = data.points_for_plot
201
+
202
+ # Default x/y selection: column 0 and 1
203
+ xs = points[:,0]
204
+ ys = points[:,1]
205
+
206
+
207
+ ls = next(ls_cycle)
208
+ lc = next(lc_cycle)
209
+ mk = next(mk_cycle) # still consume, even if not used (keeps cycles aligned)
210
+ al = next(al_cycle)
211
+
212
+ lw = getattr(axes_spec, "linewidth", 1.0) * style_scale
213
+ ms = getattr(axes_spec, "marker_size", 4.0) * style_scale
214
+
215
+ if ls == "|":
216
+ # Stick plot: draw a vertical line at each x, from baseline to y.
217
+ baseline = axes_spec.y_left_lim if axes_spec.y_left_lim is not None else 0.0
218
+ ax.vlines(
219
+ xs,
220
+ baseline,
221
+ ys,
222
+ colors=lc,
223
+ linestyles="--",
224
+ linewidth=lw,
225
+ alpha=al,
226
+ label=data.labels.brief_summary,
227
+ )
228
+ else:
229
+ ax.plot(
230
+ xs,
231
+ ys,
232
+ linestyle=ls,
233
+ color=lc,
234
+ marker=mk,
235
+ alpha=al,
236
+ linewidth=lw,
237
+ markersize=ms,
238
+ label=data.labels.brief_summary,
239
+ )
240
+
241
+
242
+ # Axis labels (axis-title fontsize should be slightly smaller on inset axes)
243
+ axis_title_fs = getattr(axes_spec, "axis_title_font_size", global_fontsize) * style_scale
244
+ ax.set_xlabel(axes_spec.x_axis_title, fontsize=axis_title_fs)
245
+ ax.set_ylabel(axes_spec.y_axis_title, fontsize=axis_title_fs)
246
+
247
+ # Limits
248
+ if axes_spec.x_left_lim is not None or axes_spec.x_right_lim is not None:
249
+ ax.set_xlim(axes_spec.x_left_lim, axes_spec.x_right_lim)
250
+ if axes_spec.y_left_lim is not None or axes_spec.y_right_lim is not None:
251
+ ax.set_ylim(axes_spec.y_left_lim, axes_spec.y_right_lim)
252
+
253
+ # Log scale
254
+ _maybe_set_log_scale(ax, axes_obj)
255
+
256
+ # Ticks
257
+ if getattr(axes_spec, "major_tick", None):
258
+ _apply_ticks(ax, axes_spec.major_tick, global_fontsize)
259
+ else:
260
+ ax.tick_params(labelsize=global_fontsize)
261
+
262
+ if getattr(axes_spec, "minor_tick", None):
263
+ ax.minorticks_on()
264
+ _apply_ticks(ax, axes_spec.minor_tick, global_fontsize)
265
+
266
+ # Grids
267
+ if getattr(axes_spec, "major_grid", None):
268
+ _apply_grid(ax, axes_spec.major_grid, "major")
269
+ if getattr(axes_spec, "minor_grid", None):
270
+ ax.minorticks_on()
271
+ _apply_grid(ax, axes_spec.minor_grid, "minor")
272
+
273
+ # Annotations
274
+ if getattr(axes_spec, "annotation", None):
275
+ for ann in axes_spec.annotation:
276
+ arrowprops = None
277
+ xy = (ann.text_x, ann.text_y)
278
+
279
+ if ann.arrow:
280
+ arrowprops = dict(
281
+ arrowstyle=_safe_arrowstyle(ann.arrow.arrowstyle),
282
+ color=ann.arrow.color,
283
+ linewidth=ann.arrow.linewidth,
284
+ )
285
+ xy = (ann.arrow.point_x, ann.arrow.point_y)
286
+
287
+ ax.annotate(
288
+ ann.text,
289
+ xy=xy,
290
+ xytext=(ann.text_x, ann.text_y),
291
+ fontsize=ann.fontsize,
292
+ arrowprops=arrowprops,
293
+ )
294
+
295
+ # Collect legend handles/labels (legend placement is handled later)
296
+ h, l = ax.get_legend_handles_labels()
297
+ per_ax_info.append((ax, h, l, axes_spec))
298
+
299
+ # ---------- Legend handling ----------
300
+ def resolve_legend_spec(axes_spec: AxesSpec) -> Optional[LegendSpec]:
301
+ # FigureSpec.legend is the global override/fallback (as you required earlier)
302
+ return getattr(axes_spec, "legend", None) or getattr(fig_spec, "legend", None)
303
+
304
+ if muti_spec is None:
305
+ # Default: each subplot draws its own legend
306
+ for ax, h, l, axes_spec in per_ax_info:
307
+ _draw_axes_legend(ax, h, l, resolve_legend_spec(axes_spec))
308
+
309
+ elif isinstance(muti_spec, StackAxesSpec):
310
+ for ax, h, l, axes_spec in per_ax_info:
311
+ _draw_axes_legend(ax, h, l, axes_spec.legend)
312
+
313
+ elif isinstance(muti_spec, InsertAxesSpec):
314
+ # InsertAxesSpec uses exactly the first two Axes for plotting (main + inset)
315
+ holder = muti_spec.legend_holder
316
+ if len(per_ax_info) >= 2:
317
+ (ax0, h0, l0, spec0), (ax1, h1, l1, spec1) = per_ax_info[:2]
318
+ handles = h0 + h1
319
+ labels = l0 + l1
320
+
321
+ if holder == "first axes":
322
+ _draw_axes_legend(ax0, handles, labels, resolve_legend_spec(spec0))
323
+ elif holder == "last axes":
324
+ # IMPORTANT: all legends must appear on the last plotting axes (the inset axes here)
325
+ _draw_axes_legend(ax1, handles, labels, resolve_legend_spec(spec1))
326
+ elif holder == "figure":
327
+ _draw_figure_legend(matfig, handles, labels, getattr(fig_spec, "legend", None))
328
+
329
+ elif isinstance(muti_spec, DualYAxesSpec):
330
+ # DualYAxesSpec: bind x-axis; only first two Axes are used.
331
+ if len(per_ax_info) >= 2:
332
+ (ax0, h0, l0, spec0), (_ax1, h1, l1, _spec1) = per_ax_info[:2]
333
+ handles = h0 + h1
334
+ labels = l0 + l1
335
+ _draw_axes_legend(ax0, handles, labels, resolve_legend_spec(spec0))
336
+
337
+ return matfig