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/bc_plotly.py ADDED
@@ -0,0 +1,575 @@
1
+ # bc_plotly.py — analog of bc.plotly.R (.bc.plotly)
2
+ #
3
+ # Renders a bar chart from ALREADY-TABULATED input, the same division
4
+ # of labor as lessR: aggregation happens upstream (later, in Chart()
5
+ # and X()); this function only renders.
6
+ #
7
+ # Input contract (mirrors the R named-vector / table contract):
8
+ # 1-D pandas Series: index = category names, values = counts/means
9
+ # 2-D pandas DataFrame: rows = `by` levels, columns = x categories
10
+ # (the shape produced by pandas.crosstab(by, x))
11
+ #
12
+ # counts= carries the frequencies behind heights that Chart has
13
+ # already rescaled to proportions (stack100), so the hover
14
+ # percentages and the labels="input" text report the data.
15
+ #
16
+ # One deliberate deviation from the R source: in lessR the caller
17
+ # computes axis ticks (ax) and grid positions (gridT) upstream. Here,
18
+ # when ax is None they are computed internally with utils.pretty(),
19
+ # so bc_plotly() is callable on its own until Chart() exists.
20
+ #
21
+ # gap= and break_x= are rendering controls that R applies only in its
22
+ # base-graphics path (.bc.plotly never receives them); scale_y=
23
+ # reaches R's plotly through the ax/gridT it computes upstream.
24
+
25
+ import math
26
+
27
+ import numpy as np
28
+ import pandas as pd
29
+ import plotly.graph_objects as go
30
+
31
+ from .utils import fmt, get_option, pretty
32
+ from .plotly_utils import (
33
+ BASE_COLORS, as_plotly_color, axis_cat, axis_format, axis_num,
34
+ contrast_text_for_hex, facet_fig, facet_pos, finish_facet,
35
+ abbrev, get_tick_fmt, is_integer_valued, legend_style,
36
+ plot_border, plotly_style, to_hex, x_grid, y_grid,
37
+ )
38
+
39
+ _LABEL_VALUES = ("%", "input", "prop", "off")
40
+
41
+
42
+ def _as_list(v):
43
+ if v is None:
44
+ return []
45
+ if isinstance(v, (list, tuple)):
46
+ return list(v)
47
+ return [v]
48
+
49
+
50
+ def _rep_len(v, n):
51
+ v = _as_list(v)
52
+ return [v[i % len(v)] for i in range(n)]
53
+
54
+
55
+ def _pick(v, name, i):
56
+ """Color for group `name` (position i): by name if v is a dict,
57
+ else recycled by position. R analog: .pick()"""
58
+ if isinstance(v, dict):
59
+ if name in v:
60
+ return v[name]
61
+ v = list(v.values())
62
+ v = _as_list(v)
63
+ if not v:
64
+ return "black"
65
+ return v[i % len(v)]
66
+
67
+
68
+ def _bar_labels(vals, share, labels, digits_d, counts=None,
69
+ labels_decimals=None):
70
+ """vals are the plotted bar heights, share the proportion each
71
+ one represents. counts, supplied when the heights are already
72
+ proportions (Chart's stack100), are the frequencies behind them
73
+ that labels="input" displays in their place.
74
+ R analog: bc.main.R x.txt <- as.character(x.count)
75
+
76
+ labels_decimals sets the decimal places of the label text.
77
+ None keeps each mode's own default, which is what a direct
78
+ call to bc_plotly() gets; Chart() always resolves a value."""
79
+ if labels == "off":
80
+ return ["" for _ in vals]
81
+ if labels == "input":
82
+ src = vals if counts is None else counts
83
+ if labels_decimals is not None:
84
+ d = int(labels_decimals)
85
+ elif counts is None:
86
+ d = digits_d
87
+ else:
88
+ d = 0 if is_integer_valued(counts) else digits_d
89
+ return [fmt(v, d) for v in src]
90
+ if labels == "%":
91
+ d = 0 if labels_decimals is None else int(labels_decimals)
92
+ return [f"{100 * s:.{d}f}%" for s in share]
93
+ d = 2 if labels_decimals is None else int(labels_decimals)
94
+ return [f"{s:.{d}f}" for s in share] # "prop"
95
+
96
+
97
+ def _break_labels(cats, break_x):
98
+ """Category labels for the categorical axis: a space becomes a
99
+ line break when break_x, and "~" is the non-breaking space that
100
+ survives it. R analog: .get.val.ln() in zzz.R"""
101
+ if break_x:
102
+ return [c.replace(" ", "<br>").replace("~", " ")
103
+ for c in cats]
104
+ return [c.replace("~", " ") for c in cats]
105
+
106
+
107
+ def _bar_gaps(gap, beside, n_groups):
108
+ """R's barplot space= (gaps in multiples of bar width) as
109
+ plotly's bargap / bargroupgap (fractions of a category slot).
110
+ gap=None leaves plotly's own defaults in place.
111
+ R analog: bc.main.R gap default c(0.1, 1) beside, else 0.2"""
112
+ if gap is None:
113
+ return None, None
114
+ if isinstance(gap, (list, tuple, np.ndarray)):
115
+ within, between = float(gap[0]), float(gap[-1])
116
+ else:
117
+ within = between = float(gap)
118
+ if within < 0 or between < 0:
119
+ raise ValueError("gap cannot be negative")
120
+ if not beside:
121
+ return between / (1.0 + between), None
122
+ # a slot holds n_groups bars, the gaps between them, and one
123
+ # between-group gap; plotly splits what bargap leaves into
124
+ # n_groups equal sub-slots, each bargroupgap of it empty
125
+ k = max(1, n_groups)
126
+ slot = k + (k - 1) * within + between
127
+ return between / slot, (k - 1) * within / (k + (k - 1) * within)
128
+
129
+
130
+ def _decimals(v):
131
+ """Decimal places needed to write v exactly. R analog: .max.dd()"""
132
+ s = f"{float(v):.10f}".rstrip("0")
133
+ return len(s.split(".")[1]) if "." in s else 0
134
+
135
+
136
+ def bc_plotly(x, x_name=None, y_name=None, by_name=None,
137
+ x_lab=None, y_lab=None,
138
+ fill=None, border="off", opacity=None,
139
+ beside=False, horiz=False, ax=None, grid=None,
140
+ counts=None, gap=None, scale_y=None, break_x=False,
141
+ digits_d=None, main=None, rotate_x=0, rotate_y=0,
142
+ axis_fmt="K", axis_x_pre="", axis_y_pre="",
143
+ labels=None, labels_size=0.90, labels_color=None,
144
+ labels_position=None, labels_decimals=None,
145
+ labels_autocontrast=False,
146
+ legend_title=None, legend_position=None,
147
+ legend_labels=None, legend_horiz=False,
148
+ legend_size=None, legend_abbrev=None,
149
+ legend_adjust=0, style_opts=None):
150
+
151
+ # R analog: missing(labels); None = caller did not supply labels=
152
+ missing_labels = labels is None
153
+ if not missing_labels and labels not in _LABEL_VALUES:
154
+ raise ValueError(f"labels must be one of {_LABEL_VALUES}")
155
+ if missing_labels:
156
+ labels = "input" # both R paths override to "input"
157
+
158
+ # labels_position None = auto-tune: plotly places each label
159
+ # inside its bar, or outside when the bar is too small to hold
160
+ # it. Stacked segments always label inside — outside placement
161
+ # would stack the labels on top of the next segment.
162
+ if labels_position not in (None, "in", "out"):
163
+ raise ValueError('labels_position must be "in" or "out"')
164
+
165
+ two_d = isinstance(x, pd.DataFrame)
166
+ if not two_d and not isinstance(x, pd.Series):
167
+ raise TypeError("x must be a pandas Series (1-D) or "
168
+ "DataFrame (2-D, rows = by levels)")
169
+
170
+ # counts: the frequencies behind heights that are already
171
+ # proportions (Chart's stack100), so the hover percentages and
172
+ # the labels="input" text come from the data, not the heights
173
+ prop_scaled = counts is not None
174
+ cnt = None
175
+ if prop_scaled:
176
+ cnt = np.asarray(counts, dtype=float)
177
+ if cnt.shape != np.asarray(x, dtype=float).shape:
178
+ raise ValueError("counts must have the same shape as x")
179
+
180
+ # derive names from pandas metadata when not given
181
+ if x_name is None:
182
+ x_name = (x.columns.name if two_d else x.index.name) or "x"
183
+ if y_name is None:
184
+ y_name = "Count" if two_d else (x.name or "Count")
185
+ if two_d and by_name is None:
186
+ by_name = x.index.name or "by"
187
+ x_lab = x_name if x_lab is None else x_lab
188
+ y_lab = y_name if y_lab is None else y_lab
189
+ if digits_d is None:
190
+ digits_d = get_option("digits_d", 2)
191
+ if style_opts is None:
192
+ style_opts = plotly_style()
193
+ if fill is None:
194
+ fill = BASE_COLORS
195
+
196
+ title_size = round(16 * get_option("main_size", 1))
197
+ alpha_fill = 0.92 if opacity is None else float(opacity)
198
+
199
+ # labels_size arrives as a cex value (e.g. 0.90); convert to px
200
+ labels_px = max(8, round(labels_size * 14))
201
+
202
+ # % lines in hover only when data are counts (labels="input"
203
+ # passed explicitly signals means / deviations)
204
+ show_pct_hover = missing_labels or labels != "input"
205
+
206
+ stacked = two_d and not beside
207
+ if stacked and labels_position == "out":
208
+ raise ValueError(
209
+ 'labels_position="out" is not meaningful for a stacked '
210
+ "bar chart (a by= variable without beside=)")
211
+ if stacked:
212
+ text_pos = "inside"
213
+ elif labels_position == "out":
214
+ text_pos = "outside"
215
+ elif labels_position == "in":
216
+ text_pos = "inside"
217
+ else:
218
+ text_pos = "auto" # per-bar in/out, the auto-tune
219
+ # a label drawn outside sits on the panel background, not the
220
+ # bar fill, so it gets the axis text color (R: Chart.R ~287)
221
+ out_col = (to_hex(labels_color) if labels_color is not None
222
+ else to_hex(get_option("axis_color", "black")))
223
+
224
+ def text_colors(fill_hex, n):
225
+ if labels_color is not None:
226
+ return _rep_len(to_hex(labels_color), n)
227
+ if labels_autocontrast:
228
+ if isinstance(fill_hex, list):
229
+ return [contrast_text_for_hex(c) for c in fill_hex]
230
+ return _rep_len(contrast_text_for_hex(fill_hex), n)
231
+ # default: white labels inside the bar (like pie/sunburst);
232
+ # labels drawn OUTSIDE keep out_col, dark on the panel
233
+ return _rep_len("white", n)
234
+
235
+ # ------------------ 1-D ------------------
236
+ if not two_d:
237
+ cats = [str(c) for c in x.index]
238
+ vals = x.to_numpy(dtype=float)
239
+
240
+ total_n = np.nansum(vals)
241
+ share = (vals / total_n if total_n > 0
242
+ else np.full(len(vals), np.nan))
243
+
244
+ tick_fmt = get_tick_fmt(vals, digits_d)
245
+ pct_line = ("<br>% of total: %{customdata:.1%}"
246
+ if show_pct_hover else "")
247
+ if not horiz:
248
+ hover = (f"{x_name}: %{{x}}<br>"
249
+ f"{y_name}: %{{y:{tick_fmt}}}"
250
+ f"{pct_line}<extra></extra>")
251
+ else:
252
+ hover = (f"{x_name}: %{{y}}<br>"
253
+ f"{y_name}: %{{x:{tick_fmt}}}"
254
+ f"{pct_line}<extra></extra>")
255
+
256
+ fill_vec = as_plotly_color(_rep_len(fill, len(cats)))
257
+ border_vec = as_plotly_color(_rep_len(border, len(cats)))
258
+
259
+ txt = _bar_labels(vals, share, labels, digits_d, counts=cnt,
260
+ labels_decimals=labels_decimals)
261
+ txt_col = text_colors(fill_vec, len(cats))
262
+
263
+ plt = go.Figure(go.Bar(
264
+ x=cats if not horiz else vals,
265
+ y=vals if not horiz else cats,
266
+ orientation=None if not horiz else "h",
267
+ marker=dict(
268
+ color=fill_vec,
269
+ opacity=alpha_fill,
270
+ line=dict(color=border_vec, width=1),
271
+ ),
272
+ customdata=share,
273
+ hovertemplate=hover,
274
+ text=txt,
275
+ textposition=text_pos,
276
+ insidetextanchor="middle",
277
+ textfont=dict(size=labels_px),
278
+ insidetextfont=dict(color=txt_col),
279
+ outsidetextfont=dict(color=out_col),
280
+ cliponaxis=False,
281
+ ))
282
+
283
+ cat_array = list(dict.fromkeys(cats))
284
+ stack_max = np.nanmax(vals) if len(vals) else 1
285
+ stack_min = np.nanmin(vals) if len(vals) else 0
286
+
287
+ # ------------------ 2-D ------------------
288
+ else:
289
+ groups = [str(g) for g in x.index]
290
+ # the key text: legend_labels renames the by levels and
291
+ # legend_abbrev truncates them. R analog: legend_labels /
292
+ # legend_abbrev of Chart()
293
+ if legend_labels is not None:
294
+ if len(legend_labels) != len(groups):
295
+ raise ValueError(
296
+ f"legend_labels has {len(legend_labels)} entries "
297
+ f"for {len(groups)} levels of the by variable")
298
+ key_names = [str(v) for v in legend_labels]
299
+ else:
300
+ key_names = list(groups)
301
+ key_names = [abbrev(k, legend_abbrev) for k in key_names]
302
+ cats = [str(c) for c in x.columns]
303
+ mat = x.to_numpy(dtype=float)
304
+
305
+ # percentages come from the counts when the heights have
306
+ # already been rescaled to proportions (stack100)
307
+ base = mat if cnt is None else cnt
308
+ total_n = np.nansum(base)
309
+ share_total = (base / total_n if total_n > 0
310
+ else np.full(base.shape, np.nan))
311
+ x_totals = np.nansum(base, axis=0)
312
+ with np.errstate(invalid="ignore", divide="ignore"):
313
+ share_within_x = np.where(x_totals != 0,
314
+ base / x_totals, np.nan)
315
+
316
+ tick_fmt = get_tick_fmt(mat.ravel(), digits_d)
317
+ pct_lines_2d = (
318
+ f"<br>% of {x_name}: %{{customdata[1]:.1%}}"
319
+ "<br>% of total: %{customdata[0]:.1%}"
320
+ if show_pct_hover else "")
321
+ if not horiz:
322
+ hover = (f"{x_name}: %{{x}}<br>"
323
+ f"{by_name}: %{{fullData.name}}<br>"
324
+ f"{y_name}: %{{y:{tick_fmt}}}"
325
+ f"{pct_lines_2d}<extra></extra>")
326
+ else:
327
+ hover = (f"{x_name}: %{{y}}<br>"
328
+ f"{by_name}: %{{fullData.name}}<br>"
329
+ f"{y_name}: %{{x:{tick_fmt}}}"
330
+ f"{pct_lines_2d}<extra></extra>")
331
+
332
+ plt = go.Figure()
333
+
334
+ for i, gname in enumerate(groups):
335
+ row = mat[i]
336
+ fill_i = as_plotly_color(_pick(fill, gname, i))
337
+ border_i = as_plotly_color(_pick(border, gname, i))
338
+
339
+ cd = np.stack([share_total[i], share_within_x[i]],
340
+ axis=-1)
341
+ # a stack100 bar IS its within-x percentage, so that is
342
+ # what "%"/"prop" report there (R: .fmt(x * 100))
343
+ txt = _bar_labels(
344
+ row,
345
+ share_within_x[i] if prop_scaled else share_total[i],
346
+ labels, digits_d,
347
+ counts=None if cnt is None else cnt[i],
348
+ labels_decimals=labels_decimals)
349
+ txt_col = text_colors(fill_i, len(row))
350
+
351
+ plt.add_trace(go.Bar(
352
+ x=cats if not horiz else row,
353
+ y=row if not horiz else cats,
354
+ orientation=None if not horiz else "h",
355
+ name=key_names[i],
356
+ legendgroup=gname,
357
+ marker=dict(
358
+ color=fill_i,
359
+ opacity=alpha_fill,
360
+ line=dict(color=border_i, width=1),
361
+ ),
362
+ customdata=cd,
363
+ hovertemplate=hover,
364
+ text=txt,
365
+ textposition=text_pos,
366
+ insidetextanchor="middle",
367
+ textfont=dict(size=labels_px),
368
+ insidetextfont=dict(color=txt_col),
369
+ outsidetextfont=dict(color=out_col),
370
+ cliponaxis=False,
371
+ ))
372
+
373
+ plt.update_layout(barmode="group" if beside else "stack")
374
+
375
+ cat_array = list(dict.fromkeys(cats))
376
+ if beside:
377
+ stack_max = np.nanmax(mat)
378
+ stack_min = np.nanmin(mat)
379
+ else: # stacked: positive and negative runs sum separately
380
+ stack_max = np.nanmax(
381
+ np.nansum(np.clip(mat, 0, None), axis=0))
382
+ stack_min = np.nanmin(
383
+ np.nansum(np.clip(mat, None, 0), axis=0))
384
+
385
+ # --- axes & background ------------------------------------------
386
+ # ax not supplied: compute tick positions here (see header note);
387
+ # value-axis labels through the axis_fmt policies ("60K"), as in
388
+ # R (.axis.format), with the prefix of the physical value axis
389
+ val_range = None
390
+ if ax is None and scale_y is not None:
391
+ # (min, max, n_intervals) gives n_intervals + 1 ticks, the
392
+ # axTicks(axp=) contract; R caps the tick decimals at 2
393
+ lo, hi = float(scale_y[0]), float(scale_y[1])
394
+ tickvals = list(np.linspace(lo, hi, int(scale_y[2]) + 1))
395
+ nd = min(2, max(_decimals(t) for t in tickvals))
396
+ ax = {"tickvals": tickvals,
397
+ "ticktext": axis_format(
398
+ tickvals, nd, axis_fmt,
399
+ axis_x_pre if horiz else axis_y_pre)}
400
+ val_range = [lo, hi]
401
+ if ax is None:
402
+ tickvals = pretty(min(0.0, float(stack_min)),
403
+ max(0.0, float(stack_max)))
404
+ ax = {"tickvals": tickvals,
405
+ "ticktext": axis_format(
406
+ tickvals, digits_d, axis_fmt,
407
+ axis_x_pre if horiz else axis_y_pre)}
408
+ if grid is None:
409
+ grid = ax["tickvals"]
410
+
411
+ # category labels wrap at their spaces when break_x; the bar
412
+ # coordinates stay the raw category values
413
+ cat_text = _break_labels(cat_array, break_x)
414
+ cat_ticks = dict(tickmode="array",
415
+ tickvals=list(range(len(cat_array))),
416
+ ticktext=cat_text)
417
+
418
+ bargap, bargroupgap = _bar_gaps(
419
+ gap, beside and two_d, len(x.index) if two_d else 1)
420
+
421
+ border_shapes = plot_border()
422
+
423
+ if horiz:
424
+ y_axis_cat = axis_cat(x_lab)
425
+ y_axis_cat.update(categoryorder="array",
426
+ categoryarray=cat_array, **cat_ticks)
427
+ if rotate_y:
428
+ y_axis_cat["tickangle"] = -rotate_y
429
+ val_axis = axis_num(y_lab, ax["tickvals"], ax["ticktext"])
430
+ if rotate_x:
431
+ val_axis["tickangle"] = -rotate_x
432
+ if val_range is not None:
433
+ val_axis["range"] = val_range
434
+ plt.update_layout(
435
+ xaxis=val_axis,
436
+ yaxis=y_axis_cat,
437
+ shapes=x_grid(grid) + border_shapes,
438
+ template=None,
439
+ )
440
+ else:
441
+ x_axis_cat = axis_cat(x_lab)
442
+ x_axis_cat.update(categoryorder="array",
443
+ categoryarray=cat_array,
444
+ tickangle=-rotate_x, **cat_ticks)
445
+ val_axis = axis_num(y_lab, ax["tickvals"], ax["ticktext"])
446
+ if rotate_y:
447
+ val_axis["tickangle"] = -rotate_y
448
+ if val_range is not None:
449
+ val_axis["range"] = val_range
450
+ plt.update_layout(
451
+ xaxis=x_axis_cat,
452
+ yaxis=val_axis,
453
+ shapes=y_grid(grid) + border_shapes,
454
+ template=None,
455
+ )
456
+
457
+ if bargap is not None:
458
+ plt.update_layout(bargap=bargap)
459
+ if bargroupgap is not None:
460
+ plt.update_layout(bargroupgap=bargroupgap)
461
+
462
+ plt.update_layout(
463
+ plot_bgcolor=to_hex(style_opts["panel_fill"]),
464
+ paper_bgcolor=to_hex(style_opts["window_fill"]),
465
+ )
466
+
467
+ if by_name:
468
+ plt.update_layout(legend=legend_style(
469
+ by_name, style_opts, title=legend_title,
470
+ position=legend_position, horiz=legend_horiz,
471
+ size=legend_size, adjust=legend_adjust,
472
+ abbrev_n=legend_abbrev))
473
+
474
+ if main:
475
+ plt.update_layout(
476
+ title=dict(text=main, x=0.5, xanchor="center",
477
+ y=0.99, yanchor="top",
478
+ font=dict(size=title_size)),
479
+ margin=dict(t=round(title_size * 2.2)),
480
+ )
481
+
482
+ # R's .finalize_plotly_widget() is RStudio-viewer machinery with
483
+ # no Python counterpart; the figure itself is the return value
484
+ return plt
485
+
486
+
487
+ def bc_facet_plotly(x, x_name=None, facet_name=None,
488
+ x_lab=None, y_lab=None,
489
+ fill=None, border="off", opacity=None,
490
+ proportion=False, digits_d=0, main=None,
491
+ n_col=1, axis_fmt="K", axis_x_pre="",
492
+ rotate_x=0, rotate_y=0,
493
+ style_opts=None):
494
+ """Faceted bar chart of counts: one panel per facet level,
495
+ stacked on a shared count axis with horizontal bars, as in the
496
+ lattice Trellis bar chart (barchart(x ~ Count | facet)) that
497
+ R renders for Chart(x, facet=). Input: DataFrame with rows =
498
+ facet levels, columns = x categories, values = counts.
499
+ n_col > 1 lays the panels out as a grid (the lattice n_col),
500
+ filled bottom-up as in finish_facet.
501
+ R analog: .bar.lattice T.type="bar" (plotly-only port)"""
502
+
503
+ if not isinstance(x, pd.DataFrame):
504
+ raise TypeError("x must be a pandas DataFrame with rows = "
505
+ "facet levels, columns = x categories")
506
+ if x_name is None:
507
+ x_name = x.columns.name or "x"
508
+ if facet_name is None:
509
+ facet_name = x.index.name or "facet"
510
+ if style_opts is None:
511
+ style_opts = plotly_style()
512
+ if fill is None:
513
+ fill = BASE_COLORS
514
+
515
+ fac_levels = [str(g) for g in x.index]
516
+ cats = [str(c) for c in x.columns]
517
+ mat = x.to_numpy(dtype=float)
518
+ n_f = len(fac_levels)
519
+
520
+ if proportion: # per-panel, ~ prop.table margin
521
+ row_tot = mat.sum(axis=1, keepdims=True)
522
+ mat = np.divide(mat, row_tot, out=np.zeros_like(mat),
523
+ where=row_tot > 0)
524
+ val_name = "Proportion" if proportion else "Count"
525
+ if x_lab is None:
526
+ x_lab = f"{val_name} of {x_name}"
527
+ if y_lab is None:
528
+ y_lab = x_name
529
+
530
+ alpha_fill = 0.92 if opacity is None else float(opacity)
531
+ fill_vec = as_plotly_color(_rep_len(fill, len(cats)))
532
+ border_vec = as_plotly_color(_rep_len(border, len(cats)))
533
+
534
+ axT1 = pretty(0.0, float(np.nanmax(mat)) if mat.size else 1.0)
535
+ ax = {"axT1": axT1,
536
+ "axL1": axis_format(axT1, digits_d, axis_fmt,
537
+ axis_x_pre)}
538
+
539
+ n_col = max(1, min(int(n_col or 1), n_f))
540
+ fig = facet_fig(math.ceil(n_f / n_col), n_col)
541
+ for i, lv in enumerate(fac_levels):
542
+ row = mat[i]
543
+ hover = [
544
+ (f"{x_name}: {c}"
545
+ f"<br>{val_name}: "
546
+ + (f"{v:.{max(2, digits_d)}f}" if proportion
547
+ else f"{v:g}")
548
+ + f"<br>{facet_name}: {lv}")
549
+ for c, v in zip(cats, row)]
550
+ r, c = facet_pos(i, n_f, n_col) # first level bottom-left
551
+ fig.add_trace(go.Bar(
552
+ x=row, y=cats, orientation="h",
553
+ marker=dict(color=fill_vec, opacity=alpha_fill,
554
+ line=dict(color=border_vec, width=1)),
555
+ hoverinfo="text", hovertext=hover,
556
+ showlegend=False,
557
+ ), row=r, col=c)
558
+
559
+ finish_facet(fig, fac_levels, ax, x_lab, y_lab, gridT1=axT1,
560
+ style_opts=style_opts, y_cat=cats, n_col=n_col)
561
+ if rotate_x:
562
+ fig.update_xaxes(tickangle=-rotate_x)
563
+ if rotate_y:
564
+ fig.update_yaxes(tickangle=-rotate_y)
565
+
566
+ if main:
567
+ title_size = round(16 * get_option("main_size", 1))
568
+ fig.update_layout(
569
+ title=dict(text=main, x=0.5, xanchor="center",
570
+ y=0.99, yanchor="top",
571
+ font=dict(size=title_size,
572
+ color=to_hex(get_option(
573
+ "lab_color", "black")))),
574
+ margin=dict(t=round(title_size * 2.2)))
575
+ return fig