lessPython 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.
Files changed (82) hide show
  1. lessPy/ANOVA.py +680 -0
  2. lessPy/Chart.py +1055 -0
  3. lessPy/Correlation.py +236 -0
  4. lessPy/Flows.py +116 -0
  5. lessPy/Logit.py +615 -0
  6. lessPy/Prop_test.py +267 -0
  7. lessPy/Regression.py +1491 -0
  8. lessPy/VariableLabels.py +119 -0
  9. lessPy/X.py +426 -0
  10. lessPy/XY.py +2007 -0
  11. lessPy/__init__.py +60 -0
  12. lessPy/anova_rmd.py +227 -0
  13. lessPy/bc_plotly.py +575 -0
  14. lessPy/bubble_plotly.py +470 -0
  15. lessPy/corCFA.py +316 -0
  16. lessPy/corEFA.py +220 -0
  17. lessPy/corPrint.py +45 -0
  18. lessPy/corProp.py +73 -0
  19. lessPy/corRead.py +48 -0
  20. lessPy/corReflect.py +72 -0
  21. lessPy/corReorder.py +161 -0
  22. lessPy/corScree.py +87 -0
  23. lessPy/data/Anova_1way.csv +25 -0
  24. lessPy/data/Anova_2way.csv +49 -0
  25. lessPy/data/Anova_rb.csv +8 -0
  26. lessPy/data/Anova_rbf.csv +49 -0
  27. lessPy/data/Anova_sp.csv +57 -0
  28. lessPy/data/BodyMeas.csv +341 -0
  29. lessPy/data/Cars93.csv +94 -0
  30. lessPy/data/Employee.csv +38 -0
  31. lessPy/data/Employee_lbl.csv +9 -0
  32. lessPy/data/FreqTable99.csv +5 -0
  33. lessPy/data/Jackets.csv +1026 -0
  34. lessPy/data/Learn.csv +35 -0
  35. lessPy/data/Mach4.csv +352 -0
  36. lessPy/data/Mach4_lbl.csv +21 -0
  37. lessPy/data/Reading.csv +101 -0
  38. lessPy/data/StockPrice.csv +1489 -0
  39. lessPy/data/WeightLoss.csv +11 -0
  40. lessPy/datasets.py +46 -0
  41. lessPy/date_infer.py +112 -0
  42. lessPy/details.py +314 -0
  43. lessPy/dn_plotly.py +495 -0
  44. lessPy/dot_plotly.py +385 -0
  45. lessPy/freq_poly_plotly.py +324 -0
  46. lessPy/getColors.py +399 -0
  47. lessPy/hier_plotly.py +352 -0
  48. lessPy/hs_plotly.py +395 -0
  49. lessPy/logit_rmd.py +410 -0
  50. lessPy/order_by.py +94 -0
  51. lessPy/pie_plotly.py +292 -0
  52. lessPy/pivot.py +158 -0
  53. lessPy/plotly_utils.py +787 -0
  54. lessPy/plt_add.py +129 -0
  55. lessPy/plt_contour.py +192 -0
  56. lessPy/plt_contour_facet.py +194 -0
  57. lessPy/plt_forecast.py +677 -0
  58. lessPy/plt_mat_plotly.py +201 -0
  59. lessPy/plt_plotly.py +216 -0
  60. lessPy/plt_smooth.py +170 -0
  61. lessPy/plt_time.py +143 -0
  62. lessPy/prob_norm.py +111 -0
  63. lessPy/prob_tcut.py +131 -0
  64. lessPy/prob_znorm.py +110 -0
  65. lessPy/radar_plotly.py +201 -0
  66. lessPy/reg_rmd.py +754 -0
  67. lessPy/rename.py +33 -0
  68. lessPy/reshape.py +95 -0
  69. lessPy/showColors.py +130 -0
  70. lessPy/simCImean.py +165 -0
  71. lessPy/simCLT.py +265 -0
  72. lessPy/simFlips.py +104 -0
  73. lessPy/simMeans.py +146 -0
  74. lessPy/stats_out.py +189 -0
  75. lessPy/ttest.py +641 -0
  76. lessPy/utils.py +235 -0
  77. lessPy/vbs_plotly.py +545 -0
  78. lesspython-0.1.0.dist-info/METADATA +93 -0
  79. lesspython-0.1.0.dist-info/RECORD +82 -0
  80. lesspython-0.1.0.dist-info/WHEEL +5 -0
  81. lesspython-0.1.0.dist-info/licenses/LICENSE +338 -0
  82. lesspython-0.1.0.dist-info/top_level.txt +1 -0
lessPy/plotly_utils.py ADDED
@@ -0,0 +1,787 @@
1
+ # plotly_utils.py — analog of zzz_plotly.R
2
+ #
3
+ # Only the helpers needed by the render functions ported so far.
4
+ # Function names drop the leading "." of their R counterparts.
5
+
6
+ import math
7
+ import re
8
+
9
+ import numpy as np
10
+
11
+ from .utils import get_option
12
+
13
+ # R analog: .plotly_base_colors()
14
+ BASE_COLORS = [
15
+ "#4398D0", "#B28B2A", "#5FA140", "#D57388",
16
+ "#9A84D6", "#00A898", "#C97E5B", "#909711",
17
+ "#00A3BA", "#D26FAF", "#00A76F", "#BD76CB",
18
+ ]
19
+
20
+ # minimal named-color table; R's col2rgb() knows all R names, but
21
+ # plotly accepts CSS names directly so unknowns pass through as-is
22
+ _NAMED = {
23
+ "black": "#000000",
24
+ "white": "#FFFFFF",
25
+ "red": "#FF0000",
26
+ "blue": "#0000FF",
27
+ "green": "#00FF00",
28
+ "gray": "#BEBEBE",
29
+ "grey": "#BEBEBE",
30
+ "darkred": "#8B0000",
31
+ "darkblue": "#00008B",
32
+ "steelblue": "#4682B4",
33
+ "transparent": "#FFFFFF00",
34
+ }
35
+
36
+
37
+ def _gray_level(name):
38
+ m = re.fullmatch(r"gr[ae]y(\d{1,3})", name)
39
+ if m is None:
40
+ return None
41
+ lvl = int(m.group(1))
42
+ if lvl > 100:
43
+ return None
44
+ v = round(255 * lvl / 100)
45
+ return f"#{v:02X}{v:02X}{v:02X}"
46
+
47
+
48
+ def to_hex(col):
49
+ """Convert a color or list of colors to hex. R analog: .to_hex()"""
50
+ if col is None:
51
+ return None
52
+ if isinstance(col, (list, tuple)):
53
+ return [to_hex(c) for c in col]
54
+
55
+ c1 = str(col).strip()
56
+ if c1.lower() == "off":
57
+ c1 = "transparent"
58
+
59
+ # rgb()/rgba(): retain alpha if present
60
+ m = re.fullmatch(r"rgba?\(([^)]+)\)", c1)
61
+ if m:
62
+ parts = [float(p) for p in m.group(1).split(",")]
63
+ r, g, b = (int(round(max(0, min(255, p)))) for p in parts[:3])
64
+ if len(parts) == 4:
65
+ a = int(round(max(0, min(1, parts[3])) * 255))
66
+ return f"#{r:02X}{g:02X}{b:02X}{a:02X}"
67
+ return f"#{r:02X}{g:02X}{b:02X}"
68
+
69
+ if re.fullmatch(r"#[0-9A-Fa-f]{6}", c1):
70
+ return c1
71
+ if re.fullmatch(r"#[0-9A-Fa-f]{3}", c1):
72
+ r, g, b = c1[1], c1[2], c1[3]
73
+ return f"#{r}{r}{g}{g}{b}{b}"
74
+ if re.fullmatch(r"#[0-9A-Fa-f]{8}", c1):
75
+ return c1
76
+
77
+ lower = c1.lower()
78
+ if lower in _NAMED:
79
+ return _NAMED[lower]
80
+ gray = _gray_level(lower)
81
+ if gray is not None:
82
+ return gray
83
+ return c1 # CSS name unknown to us; plotly handles it
84
+
85
+
86
+ def as_plotly_color(col):
87
+ """plotly.py rejects #RRGGBBAA hex; rewrite it as rgba().
88
+ R analog: .as_plotly_rgba()"""
89
+ if col is None:
90
+ return None
91
+ if isinstance(col, (list, tuple)):
92
+ return [as_plotly_color(c) for c in col]
93
+ h = to_hex(col)
94
+ if isinstance(h, str) and re.fullmatch(r"#[0-9A-Fa-f]{8}", h):
95
+ r, g, b = int(h[1:3], 16), int(h[3:5], 16), int(h[5:7], 16)
96
+ a = int(h[7:9], 16) / 255
97
+ return f"rgba({r},{g},{b},{a:.3f})"
98
+ return h
99
+
100
+
101
+ def auto_opacity(k, mode="overlay"):
102
+ """Fill opacity by number of overlapping series.
103
+ R analog: .auto_opacity()"""
104
+ if mode not in ("overlay", "fill", "lines", "stack"):
105
+ raise ValueError("bad auto_opacity mode")
106
+ k = max(1, int(k))
107
+ base = 1.00 if k == 1 else 0.65 - 0.058 * k
108
+ lo, hi = {"overlay": (0.28, 0.80), "fill": (0.20, 0.65),
109
+ "lines": (0.40, 0.90), "stack": (1.00, 1.00)}[mode]
110
+ return max(lo, min(hi, base))
111
+
112
+
113
+ def make_trans(col, alpha):
114
+ """Color(s) + alpha -> rgba() string(s).
115
+ R analog: .maketrans_plotly()"""
116
+ if isinstance(col, (list, tuple)):
117
+ return [make_trans(c, alpha) for c in col]
118
+ a = max(0.0, min(1.0, float(alpha)))
119
+ s = "" if col is None else str(col).strip().lower()
120
+ if s in ("", "na", "transparent", "off"):
121
+ return "rgba(0,0,0,0)"
122
+ h = to_hex(col)
123
+ if not str(h).startswith("#"):
124
+ return h # unknown CSS name; pass through opaque
125
+ r, g, b = hex_to_rgb3(h)
126
+ return f"rgba({r},{g},{b},{a:.3f})"
127
+
128
+
129
+ def auto_text_color(cols, bg="white", threshold=0.5):
130
+ """Black or white text per fill, from relative luminance; rgba
131
+ fills fall back to bg when fully transparent.
132
+ R analog: .auto_text_color() in zzz.R"""
133
+ def one(col):
134
+ s = str(col)
135
+ m = re.fullmatch(r"rgba?\(([^)]+)\)", s)
136
+ if m:
137
+ parts = [float(p) for p in m.group(1).split(",")]
138
+ if len(parts) == 4 and parts[3] == 0:
139
+ s = bg # fully transparent: use background
140
+ m = None
141
+ else:
142
+ r, g, b = parts[:3]
143
+ if not m:
144
+ h = to_hex(s)
145
+ if not str(h).startswith("#"):
146
+ h = to_hex(bg)
147
+ r, g, b = hex_to_rgb3(h)
148
+ lin = (_srgb_to_lin(r), _srgb_to_lin(g), _srgb_to_lin(b))
149
+ lum = 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2]
150
+ return "white" if lum < threshold else "black"
151
+ if isinstance(cols, (list, tuple)):
152
+ return [one(c) for c in cols]
153
+ return one(cols)
154
+
155
+
156
+ def build_title(x_name, by_name=None, y_name=None, stat=None,
157
+ facet_name=None):
158
+ """Auto-build a chart title from the variable names.
159
+ R analog: .plotly_build_title()"""
160
+ cap = {"mean": "Mean", "sum": "Sum", "median": "Median",
161
+ "min": "Min", "max": "Max", "sd": "SD",
162
+ "deviation": "Mean Deviation"}
163
+ if y_name == "Count":
164
+ y_name = None
165
+ if y_name is None and stat is None:
166
+ ttl = f"Count of {x_name}"
167
+ elif stat is None:
168
+ ttl = f"{y_name} by {x_name}"
169
+ else:
170
+ ttl = f"{cap.get(stat, stat.upper())} of {y_name} " \
171
+ f"by {x_name}"
172
+ if by_name:
173
+ ttl += f" by {by_name}"
174
+ if facet_name:
175
+ ttl += f" across {facet_name}"
176
+ return ttl
177
+
178
+
179
+ # where a named position puts the legend: (x, y, xanchor, yanchor).
180
+ # "right_margin" is the default, outside the panel; the rest are the
181
+ # base-R legend() keywords, placed inside the panel as they are there.
182
+ LEGEND_POSITIONS = {
183
+ "right_margin": (1.02, 0.5, "left", "middle"),
184
+ "topleft": (0.02, 0.98, "left", "top"),
185
+ "top": (0.50, 0.98, "center", "top"),
186
+ "topright": (0.98, 0.98, "right", "top"),
187
+ "left": (0.02, 0.50, "left", "middle"),
188
+ "center": (0.50, 0.50, "center", "middle"),
189
+ "right": (0.98, 0.50, "right", "middle"),
190
+ "bottomleft": (0.02, 0.02, "left", "bottom"),
191
+ "bottom": (0.50, 0.02, "center", "bottom"),
192
+ "bottomright": (0.98, 0.02, "right", "bottom"),
193
+ }
194
+
195
+
196
+ def abbrev(txt, n):
197
+ """Truncate to at most n characters. R analog: legend_abbrev"""
198
+ if n is None or txt is None:
199
+ return txt
200
+ n = int(n)
201
+ return txt if len(txt) <= n else txt[:n]
202
+
203
+
204
+ def legend_style(by_name, style_opts, title=None, position=None,
205
+ horiz=False, size=None, adjust=0, abbrev_n=None):
206
+ """Legend styling for a by= grouping, shared by the histogram,
207
+ density, and bar renderers (the VBS composite draws its own
208
+ horizontal lattice-style key instead).
209
+
210
+ title overrides the by-variable name, position names a slot in
211
+ LEGEND_POSITIONS, horiz lays the keys out in a row, size is a
212
+ character expansion factor on the text, adjust shifts the legend
213
+ horizontally (paper units), and abbrev_n truncates the title and
214
+ the key labels. R analog: the legend_* parameters of Chart()."""
215
+ if position is not None and position not in LEGEND_POSITIONS:
216
+ raise ValueError(
217
+ f"legend_position must be one of "
218
+ f"{', '.join(LEGEND_POSITIONS)}")
219
+
220
+ mult = 1.0 if size is None else float(size)
221
+ text = by_name or "" if title is None else title
222
+ leg = dict(
223
+ title=dict(text=abbrev(text, abbrev_n) or "",
224
+ font=dict(size=round(
225
+ 15 * get_option("lab_size", 1) * mult))),
226
+ font=dict(size=round(
227
+ 16 * get_option("axis_size", 0.9) * mult)),
228
+ bgcolor=to_hex(style_opts["window_fill"]),
229
+ bordercolor=to_hex(style_opts["legend_border"]),
230
+ borderwidth=1,
231
+ )
232
+ # placement stays plotly's own unless asked for, so the callers
233
+ # that only style the legend keep the layout they already had
234
+ if horiz:
235
+ leg["orientation"] = "h"
236
+ # "right_margin" is plotly's own outside-right placement, so
237
+ # leave the layout alone there unless the legend is nudged
238
+ if (position not in (None, "right_margin")) or adjust:
239
+ x, y, xanchor, yanchor = LEGEND_POSITIONS[
240
+ position or "right_margin"]
241
+ leg.update(x=x + float(adjust or 0), xanchor=xanchor,
242
+ y=y, yanchor=yanchor)
243
+ return leg
244
+
245
+
246
+ def hex_to_rgb3(h):
247
+ """R analog: .hex_to_rgb3()"""
248
+ h = h.replace("#", "").upper()
249
+ if len(h) in (3, 4):
250
+ h = h[0] * 2 + h[1] * 2 + h[2] * 2
251
+ return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
252
+
253
+
254
+ def _srgb_to_lin(c):
255
+ c = c / 255
256
+ return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4
257
+
258
+
259
+ def contrast_text_for_hex(hex_col):
260
+ """White or black text, whichever contrasts more with the fill.
261
+ R analog: .contrast_text_for_hex()"""
262
+ hex_col = to_hex(hex_col)
263
+ if not str(hex_col).startswith("#"):
264
+ return "#000000"
265
+ r, g, b = hex_to_rgb3(hex_col)
266
+ lin = (_srgb_to_lin(r), _srgb_to_lin(g), _srgb_to_lin(b))
267
+ lum = 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2]
268
+ white = 1.05 / (lum + 0.05)
269
+ black = (lum + 0.05) / 0.05
270
+ return "#FFFFFF" if white > black else "#000000"
271
+
272
+
273
+ def is_integer_valued(v):
274
+ """True if every non-missing value is a whole number."""
275
+ vals = [x for x in v if x == x] # drop NaN
276
+ return all(float(x).is_integer() for x in vals)
277
+
278
+
279
+ def get_tick_fmt(v, digits_d=2):
280
+ """d3-format string for hover/tick values. R analog: .get.tick.fmt()"""
281
+ if digits_d is None:
282
+ digits_d = 2
283
+ return "" if is_integer_valued(v) else f".{digits_d}f"
284
+
285
+
286
+ def axis_format(vals, digits_d=2, axis_fmt="K", prefix=""):
287
+ """Tick labels under the lessR axis_fmt policies. "K" (the
288
+ default): billions go scientific; multiples of 100 all past
289
+ 1000 render as thousands ("60K"); otherwise a comma
290
+ separator past 9999. ",": comma separator. ".": European
291
+ (period separator). "": plain. prefix prepends (e.g. "$").
292
+ R analog: .axis.format()"""
293
+ v = np.asarray(list(vals), dtype=float)
294
+ if len(v) == 0:
295
+ return []
296
+ fmtd = get_tick_fmt(v, digits_d)
297
+ base = [f"{u:.{digits_d}f}" if fmtd else f"{u:g}"
298
+ for u in v]
299
+ nz = np.abs(v)[np.abs(v) > 0]
300
+ if axis_fmt == "K" and not fmtd:
301
+ if np.any(np.abs(v) >= 1e9):
302
+ base = [f"{u:g}" for u in v]
303
+ elif (np.all(np.abs(v / 100 - np.round(v / 100))
304
+ < 1e-10)
305
+ and len(nz) > 0 and np.all(nz > 1000)):
306
+ base = [f"{u / 1000:g}K" for u in v]
307
+ elif np.any(v > 9999):
308
+ base = [f"{u:,.0f}" for u in v]
309
+ elif axis_fmt == ",":
310
+ base = [f"{u:,.0f}" for u in v]
311
+ elif axis_fmt == ".":
312
+ base = [f"{u:,.0f}".replace(",", ".") for u in v]
313
+ if prefix:
314
+ base = [prefix + b for b in base]
315
+ return base
316
+
317
+
318
+ def grid_style():
319
+ """R analog: .grid_style()"""
320
+ return {
321
+ "color": to_hex(get_option("grid_col", "gray85")),
322
+ "width": get_option("grid_lwd", 0.5),
323
+ "dash": get_option("grid_lty", None),
324
+ }
325
+
326
+
327
+ def _grid_lines(vals, vertical):
328
+ if vals is None or len(vals) == 0:
329
+ return []
330
+ st = grid_style()
331
+ line = {"color": st["color"], "width": st["width"]}
332
+ if st["dash"] is not None:
333
+ line["dash"] = st["dash"]
334
+ shapes = []
335
+ for v in vals:
336
+ if vertical:
337
+ pos = {"xref": "x", "yref": "paper",
338
+ "x0": v, "x1": v, "y0": 0, "y1": 1}
339
+ else:
340
+ pos = {"xref": "paper", "yref": "y",
341
+ "x0": 0, "x1": 1, "y0": v, "y1": v}
342
+ shapes.append({"type": "line", "layer": "below",
343
+ "line": dict(line), **pos})
344
+ return shapes
345
+
346
+
347
+ def x_grid(x_vals):
348
+ """Vertical grid lines at given x values. R analog: x_grid()"""
349
+ return _grid_lines(x_vals, vertical=True)
350
+
351
+
352
+ def y_grid(y_vals):
353
+ """Horizontal grid lines at given y values. R analog: y_grid()"""
354
+ return _grid_lines(y_vals, vertical=False)
355
+
356
+
357
+ def axis_base():
358
+ """R analog: axis_base()"""
359
+ return {
360
+ "zeroline": False,
361
+ "showline": True,
362
+ "linecolor": to_hex(get_option("axis_color", "black")),
363
+ "linewidth": get_option("axis_lwd", 1),
364
+ "ticks": "outside",
365
+ "ticklen": 4,
366
+ "automargin": True,
367
+ "tickfont": {
368
+ "color": to_hex(get_option("axis_color", "black")),
369
+ "size": 16 * get_option("axis_size", 0.9),
370
+ },
371
+ "title": {
372
+ "font": {
373
+ "color": to_hex(get_option("lab_color", "black")),
374
+ "size": 15 * get_option("lab_size", 1),
375
+ },
376
+ "standoff": 10,
377
+ },
378
+ "gridcolor": to_hex(get_option("grid_color", "gray90")),
379
+ "gridwidth": get_option("grid_lwd", 1),
380
+ }
381
+
382
+
383
+ def axis_num(title_txt, tickvals, ticktext):
384
+ """Numeric axis with explicit ticks. R analog: axis_num()"""
385
+ ax = axis_base()
386
+ ax["title"]["text"] = title_txt
387
+ ax.update(tickmode="array", tickvals=tickvals,
388
+ ticktext=ticktext, showgrid=False)
389
+ return ax
390
+
391
+
392
+ def axis_cat(title_txt):
393
+ """Categorical axis, grid off. R analog: axis_cat()"""
394
+ ax = axis_base()
395
+ ax["title"]["text"] = title_txt
396
+ ax["showgrid"] = False
397
+ return ax
398
+
399
+
400
+ def apply_font_size(fig, mult):
401
+ """Scale every text size in the figure by mult -- the per-call
402
+ font_size. Covers the global font, the title, each (sub)plot's
403
+ axis title and tick font, the legend, annotations, and trace
404
+ text. Text without an explicit size inherits the global font,
405
+ so scaling layout.font carries it. R analog: font_size."""
406
+ if not mult or mult == 1:
407
+ return fig
408
+
409
+ def scale(obj, attr, default=None):
410
+ if obj is None:
411
+ return
412
+ try:
413
+ cur = getattr(obj, attr)
414
+ except Exception:
415
+ return
416
+ try:
417
+ if cur is not None:
418
+ setattr(obj, attr, max(1, round(cur * mult)))
419
+ elif default is not None:
420
+ setattr(obj, attr, max(1, round(default * mult)))
421
+ except Exception:
422
+ pass
423
+
424
+ lay = fig.layout
425
+ scale(lay.font, "size", 12) # inherited default
426
+ scale(lay.title.font, "size")
427
+ scale(lay.legend.font, "size")
428
+ for ax in list(fig.select_xaxes()) + list(fig.select_yaxes()):
429
+ scale(ax.title.font, "size")
430
+ scale(ax.tickfont, "size")
431
+ for ann in lay.annotations:
432
+ scale(ann.font, "size")
433
+ for tr in fig.data:
434
+ for a in ("textfont", "insidetextfont", "outsidetextfont"):
435
+ scale(getattr(tr, a, None), "size")
436
+ return fig
437
+
438
+
439
+ def font_scaled(func):
440
+ """Wrap a plot function so a font_size= keyword scales the text
441
+ of the returned figure (font_size=1 is a no-op)."""
442
+ import functools
443
+
444
+ @functools.wraps(func)
445
+ def wrapper(*args, font_size=1, **kwargs):
446
+ fig = func(*args, **kwargs)
447
+ try:
448
+ if font_size and font_size != 1 and hasattr(fig, "layout"):
449
+ apply_font_size(fig, font_size)
450
+ except Exception:
451
+ pass
452
+ return fig
453
+
454
+ return wrapper
455
+
456
+
457
+ def sym_at(shape, i):
458
+ """Per-group marker symbol. A scalar shape is used for every
459
+ group (by= differentiates by color only, the default); a list/
460
+ tuple gives one symbol per group, recycled if short."""
461
+ if isinstance(shape, (list, tuple)):
462
+ return shape[i % len(shape)]
463
+ return shape
464
+
465
+
466
+ def square_layout(main=False, side=460):
467
+ """Figure width/height/margin giving a square plot area
468
+ (side x side px), matching R's square default device. Setting
469
+ width = side + left + right and height = side + top + bottom
470
+ makes the plotting box square regardless of tick-label width."""
471
+ left, right, bottom = 78, 28, 56
472
+ top = 60 if main else 30
473
+ return dict(width=side + left + right,
474
+ height=side + top + bottom,
475
+ margin=dict(l=left, r=right, t=top, b=bottom))
476
+
477
+
478
+ def facet_fig(n_rows, n_cols=1):
479
+ """Facet panel grid on shared axes; row n_rows is the BOTTOM
480
+ row, where the first facet levels draw (the lattice as.table
481
+ default, as in the faceted VBS)."""
482
+ from plotly.subplots import make_subplots
483
+ # plotly caps spacing at 1/(n-1); keep a 10% margin under it so
484
+ # many-panel grids (e.g. 25 facet levels) still lay out
485
+ v_space = max(0.055, min(0.16, 0.32 / n_rows))
486
+ if n_rows > 1:
487
+ v_space = min(v_space, 0.9 / (n_rows - 1))
488
+ h_space = 0.06 if n_cols > 1 else 0.2
489
+ if n_cols > 1:
490
+ h_space = min(h_space, 0.9 / (n_cols - 1))
491
+ return make_subplots(
492
+ rows=n_rows, cols=n_cols, shared_xaxes=True,
493
+ shared_yaxes=n_cols > 1,
494
+ vertical_spacing=v_space, horizontal_spacing=h_space)
495
+
496
+
497
+ def facet_pos(i, n_lvl, n_col):
498
+ """Subplot (row, col) of facet level i: the lattice fill
499
+ order — bottom row first, left to right, then upward, so the
500
+ bottom row is always full and carries the x ticks."""
501
+ n_row = math.ceil(n_lvl / max(1, n_col))
502
+ return n_row - i // n_col, i % n_col + 1
503
+
504
+
505
+ def facet_cell_label(name, level):
506
+ """Panel-strip label Var = "level" (quote non-numeric levels
507
+ only, so an integer level reads as Year = 2020, not
508
+ Year = "2020"). R analog: .lab.lv() in .plt.dist.facet /
509
+ .plt.contour.facet"""
510
+ try:
511
+ float(level)
512
+ q = str(level)
513
+ except (TypeError, ValueError):
514
+ q = f'"{level}"'
515
+ return f"{name} = {q}"
516
+
517
+
518
+ def facet_cells2(f1_order, f2_order, f1_name, f2_name):
519
+ """Cell order for the two-facet grid: panel rows = facet2
520
+ levels (first level on the top row), panel columns = facet1
521
+ levels, filled row-major — the layout of R's faceted
522
+ dist/contour renderers and the lattice facet1 * facet2
523
+ conditioning. Returns (labels, pos, n_row, n_col) for
524
+ finish_facet(pos=...)."""
525
+ labels, pos = [], []
526
+ for i2, l2 in enumerate(f2_order):
527
+ for i1, l1 in enumerate(f1_order):
528
+ labels.append(facet_cell_label(f1_name, l1) + ", "
529
+ + facet_cell_label(f2_name, l2))
530
+ pos.append((i2 + 1, i1 + 1))
531
+ return labels, pos, len(f2_order), len(f1_order)
532
+
533
+
534
+ def facet_panels(facet, facet_order, facet2=None,
535
+ facet2_order=None, facet_name=None,
536
+ facet2_name=None, n_col=1):
537
+ """Panel plan shared by the faceted renderers. One facet:
538
+ the established bottom-up fill (facet_pos) with plain level
539
+ strip labels. Two facets: the facet_cells2 top-down grid
540
+ (rows = facet2, cols = facet1) with Var = "level" cell
541
+ labels; n_col is then fixed by the facet1 levels. Returns
542
+ (labels, pos, sel, n_row, n_col): sel[i] is the boolean
543
+ row mask of panel i."""
544
+ import numpy as np
545
+ f1 = np.asarray(facet)
546
+ if facet2 is None:
547
+ n = len(facet_order)
548
+ labels = [str(lv) for lv in facet_order]
549
+ pos = [facet_pos(i, n, n_col) for i in range(n)]
550
+ sel = [f1 == lv for lv in facet_order]
551
+ return labels, pos, sel, math.ceil(n / max(1, n_col)), \
552
+ max(1, n_col)
553
+ labels, pos, n_row, n_c = facet_cells2(
554
+ facet_order, facet2_order, facet_name, facet2_name)
555
+ f2 = np.asarray(facet2)
556
+ sel = [(f1 == l1) & (f2 == l2)
557
+ for l2 in facet2_order for l1 in facet_order]
558
+ return labels, pos, sel, n_row, n_c
559
+
560
+
561
+ def finish_facet(fig, levels, ax, x_lab, y_lab, gridT1,
562
+ style_opts=None, y_cat=None, n_col=1, pos=None,
563
+ height=None, width=None):
564
+ """Facet styling shared by the faceted renderers: per-panel y
565
+ axes on a common scale (y label on the middle panel of the
566
+ first column), x ticks on the bottom row only, vertical grid
567
+ and a frame per panel, and a shaded strip label above each
568
+ panel. levels fill bottom-up, left to right (facet_pos):
569
+ levels[0] draws bottom-left. n_col > 1 lays the panels out
570
+ as a grid (the lattice n_col). y_cat: category labels for
571
+ panels whose y axis is categorical (the faceted bar chart);
572
+ ax then needs only axT1/axL1. pos: explicit (row, col) per
573
+ level — the two-facet grid (facet_cells2), which fills
574
+ top-down."""
575
+ if style_opts is None:
576
+ style_opts = plotly_style()
577
+ n = len(levels)
578
+ if pos is not None:
579
+ n_row = max(r for r, _ in pos)
580
+ n_col = max(c for _, c in pos)
581
+ else:
582
+ n_row = math.ceil(n / max(1, n_col))
583
+ mid_r = (n_row + 1) // 2
584
+ mid_c = (n_col + 1) // 2
585
+ gs = grid_style()
586
+ shapes = list(fig.layout.shapes or [])
587
+ anns = list(fig.layout.annotations or [])
588
+ s_fill = as_plotly_color(get_option("strip_fill",
589
+ "#7F7F7F37"))
590
+ s_line = to_hex(get_option("strip_color", "gray40"))
591
+ s_text = to_hex(get_option("strip_text_color", "gray15"))
592
+ strip_h = max(0.10, min(0.30, 0.05 * n_row))
593
+ # strip font: shrink so the longest label fits its panel
594
+ # (the two-facet Var = "level" cell labels run long)
595
+ s_size = round(14 * get_option("axis_size", 0.9))
596
+ panel_px = (700 - 90) / max(1, n_col)
597
+ max_len = max(len(str(lv)) for lv in levels)
598
+ if max_len > 0:
599
+ s_size = max(8, min(s_size,
600
+ int(panel_px / (0.58 * max_len))))
601
+
602
+ for i, lvl in enumerate(levels):
603
+ r, c = pos[i] if pos is not None else facet_pos(i, n,
604
+ n_col)
605
+ k_ax = (r - 1) * n_col + c
606
+ suf = "" if k_ax == 1 else str(k_ax)
607
+ show_ylab = y_lab if (c == 1 and r == mid_r) else ""
608
+ if y_cat is None:
609
+ ay = axis_num(show_ylab, ax["axT2"], ax["axL2"])
610
+ ay.update(showgrid=True,
611
+ gridcolor=to_hex(style_opts["grid_col"]),
612
+ gridwidth=1, griddash="dot",
613
+ showticklabels=c == 1,
614
+ range=[0, float(ax["axT2"][-1]) * 1.04])
615
+ else:
616
+ ay = axis_cat(show_ylab)
617
+ ay.update(categoryorder="array",
618
+ categoryarray=list(y_cat),
619
+ showticklabels=c == 1)
620
+ fig.update_yaxes(row=r, col=c, **ay)
621
+ if r == n_row:
622
+ fig.update_xaxes(
623
+ row=r, col=c,
624
+ **axis_num(x_lab if c == mid_c else "",
625
+ ax["axT1"], ax["axL1"]))
626
+ else:
627
+ fig.update_xaxes(
628
+ row=r, col=c, showline=True, ticks="",
629
+ linecolor=to_hex(get_option("axis_color",
630
+ "black")))
631
+ for v in (gridT1 or []): # vertical panel grid
632
+ shapes.append({
633
+ "type": "line", "layer": "below",
634
+ "xref": f"x{suf}", "yref": f"y{suf} domain",
635
+ "x0": v, "x1": v, "y0": 0, "y1": 1,
636
+ "line": {"color": gs["color"],
637
+ "width": gs["width"]}})
638
+ shapes.append({ # panel frame
639
+ "type": "rect",
640
+ "xref": f"x{suf} domain", "yref": f"y{suf} domain",
641
+ "x0": 0, "x1": 1, "y0": 0, "y1": 1,
642
+ "line": {"color": to_hex("gray75"), "width": 1},
643
+ "fillcolor": "rgba(0,0,0,0)"})
644
+ shapes.append({ # strip label band
645
+ "type": "rect",
646
+ "xref": f"x{suf} domain", "yref": f"y{suf} domain",
647
+ "x0": 0, "x1": 1, "y0": 1.0, "y1": 1.0 + strip_h,
648
+ "fillcolor": s_fill,
649
+ "line": {"color": s_line, "width": 1}})
650
+ anns.append(dict(
651
+ xref=f"x{suf} domain", yref=f"y{suf} domain",
652
+ x=0.5, y=1.0 + strip_h / 2, text=str(lvl),
653
+ xanchor="center", yanchor="middle",
654
+ showarrow=False,
655
+ font=dict(size=s_size, color=s_text)))
656
+
657
+ used = (set(pos) if pos is not None
658
+ else {facet_pos(i, n, n_col) for i in range(n)})
659
+ for r in range(1, n_row + 1): # ragged top row: hide
660
+ for c in range(1, n_col + 1): # axes of empty cells
661
+ if (r, c) not in used:
662
+ fig.update_xaxes(row=r, col=c, visible=False)
663
+ fig.update_yaxes(row=r, col=c, visible=False)
664
+
665
+ layout_kw = dict(
666
+ shapes=shapes, annotations=anns, template=None,
667
+ plot_bgcolor=to_hex(style_opts["panel_fill"]),
668
+ paper_bgcolor=to_hex(style_opts["window_fill"]),
669
+ height=height if height is not None else 140 + 160 * n_row,
670
+ )
671
+ if width is not None:
672
+ layout_kw["width"] = width
673
+ fig.update_layout(**layout_kw)
674
+ return fig
675
+
676
+
677
+ def facet_domains_grid(n_fac, n_col_max=3, gap_x=0.04, gap_y=0.11,
678
+ facet_size=1.0, n_col=None):
679
+ """Equal-size grid of paper-coordinate panel domains, filled
680
+ row-major from the TOP row down (plotly domain y runs
681
+ bottom-up). Each domain: dict(x=(x0, x1), y=(y0, y1), row,
682
+ col), rows/cols 1-based. facet_size < 1 shrinks each panel
683
+ about its own center. An explicit n_col (the lattice n_col)
684
+ overrides the n_col_max default.
685
+ R analog: .plotly_make_domains_grid()"""
686
+ if n_col is None:
687
+ n_col = min(n_col_max, n_fac)
688
+ n_col = max(1, min(int(n_col), n_fac))
689
+ n_row = math.ceil(n_fac / n_col)
690
+
691
+ width = max(0.01, (1 - (n_col + 1) * gap_x) / n_col)
692
+ height = max(0.01, (1 - (n_row + 1) * gap_y) / n_row)
693
+
694
+ domains = []
695
+ for k in range(n_fac):
696
+ r, c = divmod(k, n_col)
697
+ x0 = gap_x + c * (width + gap_x)
698
+ x1 = x0 + width
699
+ y1 = 1 - r * (height + gap_y)
700
+ y0 = y1 - height
701
+
702
+ cx, cy = (x0 + x1) / 2, (y0 + y1) / 2
703
+ half_w = (x1 - x0) * facet_size / 2
704
+ half_h = (y1 - y0) * facet_size / 2
705
+ domains.append(dict(
706
+ x=(max(0.0, cx - half_w), min(1.0, cx + half_w)),
707
+ y=(max(0.0, cy - half_h), min(1.0, cy + half_h)),
708
+ row=r + 1, col=c + 1))
709
+ return domains, n_row, n_col
710
+
711
+
712
+ def facet_layout(facet_levels, facet_name="",
713
+ n_col_max=3, gap_x=0.04, gap_y=0.11,
714
+ facet_size=1.0, domains=None, domain_adjust=None,
715
+ reverse_domains=False, y_base=0.054,
716
+ y_row_shift=-0.002, yanchor="top",
717
+ font_size=None, ann_adjust=None, n_col=None):
718
+ """Panel-grid domains plus a per-panel title annotation, shared
719
+ by the faceted hier/radar/dot/bubble renderers. Supply domains
720
+ to skip the grid computation (panels laid out elsewhere).
721
+ Hooks: domain_adjust(d) tweaks each domain, reverse_domains
722
+ flips their order (treemap row fix), ann_adjust(i, ann, d)
723
+ finalizes each annotation. An empty facet_name gives bare
724
+ level labels. An explicit n_col (the lattice n_col) overrides
725
+ the n_col_max grid default. R analog: .plotly_facet_layout()"""
726
+ if domains is None:
727
+ domains, _, _ = facet_domains_grid(
728
+ len(facet_levels), n_col_max, gap_x, gap_y, facet_size,
729
+ n_col=n_col)
730
+ if domain_adjust is not None:
731
+ domains = [domain_adjust(dict(d)) for d in domains]
732
+ if reverse_domains:
733
+ domains = list(reversed(domains))
734
+ if font_size is None:
735
+ font_size = round(14 * get_option("lab_size", 1))
736
+
737
+ anns = []
738
+ for i, d in enumerate(domains):
739
+ y_title = (d["y"][1] + y_base
740
+ + y_row_shift * (d.get("row", 1) - 1))
741
+ ann = dict(
742
+ text=(f"{facet_name}: {facet_levels[i]}" if facet_name
743
+ else str(facet_levels[i])),
744
+ x=(d["x"][0] + d["x"][1]) / 2, y=y_title,
745
+ xref="paper", yref="paper",
746
+ xanchor="center", yanchor=yanchor,
747
+ showarrow=False, font=dict(size=font_size))
748
+ if ann_adjust is not None:
749
+ ann = ann_adjust(i, ann, d)
750
+ anns.append(ann)
751
+ return domains, anns
752
+
753
+
754
+ def plot_border(top=True, right=True, color=None, width=None):
755
+ """Top/right frame lines of the panel. R analog: plot_border()"""
756
+ if color is None:
757
+ color = get_option("panel_border", "#808080")
758
+ if width is None:
759
+ width = get_option("panel_lwd", 1)
760
+ if isinstance(color, str) and re.fullmatch(r"gray\d+", color):
761
+ color = "gray75" # make a gray a little lighter for plotly
762
+
763
+ line = {"color": to_hex(color), "width": width}
764
+ out = []
765
+ if top:
766
+ out.append({"type": "line", "xref": "paper", "yref": "paper",
767
+ "x0": 0, "x1": 1, "y0": 1, "y1": 1, "line": line})
768
+ if right:
769
+ out.append({"type": "line", "xref": "paper", "yref": "paper",
770
+ "x0": 1, "x1": 1, "y0": 0, "y1": 1, "line": line})
771
+ return out
772
+
773
+
774
+ def plotly_style():
775
+ """Current style settings for the render functions.
776
+ R analog: .plotly_style()"""
777
+ pb = get_option("panel_border", "#808080")
778
+ return {
779
+ "grid_color": get_option("grid_color", "gray85"),
780
+ "grid_col": get_option("grid_col", "gray85"),
781
+ "lab_color": get_option("lab_color", "black"),
782
+ "legend_border": get_option("legend_border", pb),
783
+ "panel_border": pb,
784
+ "panel_fill": get_option("panel_fill", "white"),
785
+ "window_fill": get_option("window_fill", "white"),
786
+ "segment_color": get_option("segment_color", "gray40"),
787
+ }