lessPython 0.1.0__py3-none-any.whl → 0.2.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.
lessPy/Correlation.py CHANGED
@@ -27,7 +27,8 @@ class CorrelationResults:
27
27
  f"r={self.r}, p={self.pvalue}>")
28
28
 
29
29
 
30
- def Correlation(x=None, y=None, data=None, miss="pairwise",
30
+ def Correlation(x=None, y=None, data=None, filter=None,
31
+ miss="pairwise",
31
32
  show="cor", method="pearson", brief=False,
32
33
  digits_d=None, heat_map=True, main=None):
33
34
  """Correlation of two variables (with a significance test) or
@@ -42,6 +43,16 @@ def Correlation(x=None, y=None, data=None, miss="pairwise",
42
43
  if method not in ("pearson", "spearman", "kendall"):
43
44
  raise ValueError('method: "pearson", "spearman", '
44
45
  '"kendall"')
46
+ # subset rows, as in ttest(), ANOVA() and Regression(). Correlation()
47
+ # reaches its data by two routes, so filter whichever one carries
48
+ # it, always before a variable list subsets the columns
49
+ if filter is not None:
50
+ if data is not None:
51
+ data = data.query(filter)
52
+ elif isinstance(x, pd.DataFrame):
53
+ x = x.query(filter)
54
+ else:
55
+ raise ValueError("filter needs data= or a DataFrame x")
45
56
  if y is not None:
46
57
  xv = _resolve1(x, data, "x")
47
58
  yv = _resolve1(y, data, "y")
lessPy/Flows.py CHANGED
@@ -109,7 +109,11 @@ def Flows(value, stage1, stage2, stage3=None, data=None,
109
109
  y=min(0.98, y_dom[1] + 0.03),
110
110
  xanchor="center", yanchor="top",
111
111
  font=dict(size=round(18 * labels_size))),
112
- margin=dict(t=90, r=30, b=30, l=30),
112
+ # the top margin only needs to clear the title, which is
113
+ # positioned in container coordinates; a fixed 90 left a
114
+ # large gap below it
115
+ margin=dict(t=max(30, round(18 * labels_size * 2.2)),
116
+ r=30, b=30, l=30),
113
117
  font=dict(size=round(15 * labels_size)),
114
118
  template=None,
115
119
  paper_bgcolor=to_hex(get_option("window_fill", "white")))
lessPy/X.py CHANGED
@@ -35,7 +35,7 @@
35
35
  # violin bandwidth search (now the default bandwidth, as R),
36
36
  # out_cut/ID/ID_size outlier labels above the strip.
37
37
  # n_row/n_col lay the histogram/density/freq_poly facets out
38
- # as a grid (lattice bottom-up fill); not for the VBS bands.
38
+ # as a grid (reading-order fill, top-left first); not for the VBS bands.
39
39
  # Not ported: aspect= (panel aspect is figure sizing in
40
40
  # plotly), the axis-format/margin family, add= annotations,
41
41
  # themes.
lessPy/XY.py CHANGED
@@ -42,9 +42,10 @@
42
42
  # shared scatter_matrix helper (plt_mat_plotly). The faceted
43
43
  # scatter prints a summary table per grouping variable
44
44
  # (facet_summary ~ .vbs_summary_table).
45
- # The categorical-x delegations are not ported (bubble and
46
- # Cleveland displays are internal-only; categorical data
47
- # belong to Chart()). R's form="hexbin" is dropped by design:
45
+ # A categorical x or y is refused with the call of the view it
46
+ # belongs to, as XY.R's master control funnel now does (Aug
47
+ # 2026); the dot plots XY() once drew for row_names and for a
48
+ # categorical variable with stat= are Chart(form="dot")'s. R's form="hexbin" is dropped by design:
48
49
  # plotly has no hexbin trace.
49
50
 
50
51
  import math
@@ -262,7 +263,7 @@ def _xy_facet(xv, yv, by_arr, by_order, facet_arr, facet_order,
262
263
  facet2_name=None, n_col=1):
263
264
  """One scatter panel per facet level on shared axes,
264
265
  following the faceted-histogram conventions (_hs_facet):
265
- first level on the bottom panel, strip labels, legend
266
+ first level on the top panel, strip labels, legend
266
267
  entries from the first panel only. facet2: the two-facet
267
268
  grid, rows = facet2 levels."""
268
269
  labels, pos, sel, n_row_g, n_col = facet_panels(
@@ -738,7 +739,7 @@ def _ts_facet(xv, yv, facet_arr, facet_order, fill0, border0,
738
739
  facet2_order=None, facet2_name=None, n_col=1):
739
740
  """One time-series panel per facet level on shared axes,
740
741
  following the faceted-scatter conventions (_xy_facet):
741
- first level on the bottom panel, strip labels. The x axis
742
+ first level on the top panel, strip labels. The x axis
742
743
  uses plotly's native date ticks, as the single-panel time
743
744
  series does. R analog: the lattice cont_cont path for a
744
745
  date x. facet2: the two-facet grid, rows = facet2 levels."""
@@ -790,7 +791,7 @@ def _ts_facet(xv, yv, facet_arr, facet_order, fill0, border0,
790
791
  if "markers" in mode_pts else None),
791
792
  line=dict(color=to_hex(border0), width=1.5),
792
793
  hovertemplate=hover, showlegend=False,
793
- ), row=row, col=col) # first level bottom
794
+ ), row=row, col=col) # first level top-left
794
795
 
795
796
  finish_facet(fig, labels, ax, x_lab, y_lab,
796
797
  gridT1=None, style_opts=style_opts,
@@ -988,10 +989,11 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
988
989
  segments_x=None, segments_y=None,
989
990
  add=None, x1=None, y1=None, x2=None, y2=None,
990
991
  fill=None, color=None, transparency=None,
991
- pt_size=1, pt_shape="circle", jitter_x=None, jitter_y=None,
992
+ pt_size=1, pt_shape="circle", line_width=1.5,
993
+ jitter_x=None, jitter_y=None,
992
994
  MD_cut=0, out_cut=0, out_shape="circle-open", out_size=1,
993
995
  ID=None, ID_color="gray50", ID_size=0.6,
994
- fit="off", fit_power=1, fit_se=None, plot_errors=False,
996
+ fit="off", fit_power=1, fit_se=None, fit_errors=False,
995
997
  fit_new=None,
996
998
  fit_color=None, fit_lwd=None, span=0.75,
997
999
  ellipse=0, ellipse_fill=None, ellipse_color=None,
@@ -1007,7 +1009,8 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1007
1009
  axis_fmt="K", axis_x_pre="", axis_y_pre="",
1008
1010
  rotate_x=0, rotate_y=0, scale_x=None, scale_y=None,
1009
1011
  xlab=None, ylab=None, main=None, digits_d=None,
1010
- quiet=None):
1012
+ quiet=None,
1013
+ plot_errors=None):
1011
1014
  """Analytic view of the relationship between two numerical
1012
1015
  variables, optionally grouped (by=). A date x displays as a
1013
1016
  time series. Variables are strings naming columns of the
@@ -1051,19 +1054,25 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1051
1054
  if center_line not in ("off", "mean", "median", "zero"):
1052
1055
  raise ValueError(
1053
1056
  'center_line: "off", "mean", "median", or "zero"')
1057
+ if plot_errors is not None:
1058
+ raise ValueError(
1059
+ "Parameter plot_errors has been renamed to fit_errors. "
1060
+ "The segments it draws join each point to the fitted line, "
1061
+ "so they are the errors of the fit, and the name now says "
1062
+ "which fit they belong to, as fit, fit_color, and fit_se do.")
1054
1063
  if center_line != "off" and facet is not None:
1055
1064
  raise ValueError(
1056
1065
  "center_line draws on a single panel: no facet=")
1057
- if plot_errors:
1066
+ if fit_errors:
1058
1067
  if fit == "off":
1059
1068
  raise ValueError(
1060
- "plot_errors draws residual segments to the fit "
1069
+ "fit_errors draws residual segments to the fit "
1061
1070
  "line: specify fit=")
1062
1071
  if form != "scatter":
1063
- raise ValueError('plot_errors applies to form="scatter"')
1072
+ raise ValueError('fit_errors applies to form="scatter"')
1064
1073
  if facet is not None:
1065
1074
  raise ValueError(
1066
- "plot_errors draws on a single panel: no facet=")
1075
+ "fit_errors draws on a single panel: no facet=")
1067
1076
  if (scale_x is not None or scale_y is not None) \
1068
1077
  and facet is not None:
1069
1078
  raise ValueError(
@@ -1152,39 +1161,43 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1152
1161
  xlab, ylab, main, digits_d, quiet),
1153
1162
  rotate_x, rotate_y)
1154
1163
 
1155
- # ----- row_names: the data-frame row labels as a categorical
1156
- # axis -> a Cleveland dot plot, delegated to Chart(form="dot")
1157
- # (categorical variables live in Chart), the numeric variable
1158
- # its value ------------------------------------------------------
1164
+ # ----- row_names: each row label identifies a single case, a
1165
+ # categorical axis that belongs to Chart(form="dot")
1166
+ # R analog: XY.R categorical refusal, x.unique branch
1159
1167
  _ROW_KW = ("row_names", "row.names")
1160
1168
  x_row = isinstance(x, str) and x in _ROW_KW
1161
1169
  y_row = isinstance(y, str) and y in _ROW_KW
1162
1170
  if x_row or y_row:
1163
- from .Chart import Chart
1164
- val = y if x_row else x
1165
- if (not isinstance(val, str) or val in _ROW_KW
1166
- or not pd.api.types.is_numeric_dtype(
1167
- get_column(data, val, "x" if x_row else "y"))):
1171
+ cont = y if x_row else x
1172
+ raise TypeError(
1173
+ "XY() requires a continuous x and a continuous y.\n"
1174
+ "row_names is categorical.\n\n"
1175
+ "Each level of row_names identifies a single case, so\n"
1176
+ f" display the values of {cont} directly with Chart():\n"
1177
+ f" Chart('row_names', y='{cont}', form='dot')")
1178
+
1179
+ # stat, sort, and the segments_x/_y droplines served the dot
1180
+ # plot XY() once drew of a categorical variable, now Chart()'s;
1181
+ # R's stat aggregates y within bins of x (n_bins), not ported
1182
+ if stat is not None:
1183
+ cat_, cont = "x", "y"
1184
+ for a, b in ((x, y), (y, x)):
1185
+ if (isinstance(a, str) and isinstance(b, str)
1186
+ and a in data.columns and b in data.columns
1187
+ and not pd.api.types.is_numeric_dtype(data[a])):
1188
+ cat_, cont = a, b
1189
+ raise ValueError(
1190
+ "stat in XY() aggregates y within bins of x (n_bins), "
1191
+ "which is not yet ported. For a statistic of a numerical "
1192
+ "variable across the levels of a categorical one, use "
1193
+ f"Chart():\n Chart('{cat_}', y='{cont}', stat='{stat}')")
1194
+ for nm, v in (("sort", None if sort == "0" else sort),
1195
+ ("segments_x", segments_x),
1196
+ ("segments_y", segments_y)):
1197
+ if v is not None:
1168
1198
  raise ValueError(
1169
- "row_names pairs with one numerical variable")
1170
- names = data.index.astype(str)
1171
- d2 = data.copy()
1172
- d2["row_names"] = pd.Categorical(
1173
- names, categories=list(dict.fromkeys(names)))
1174
- # no by= here, so a single scatterplot fill (XY's pt_color
1175
- # default) rather than Chart's per-category dot hues
1176
- dot_fill = (get_option("pt_color", "#324E5C")
1177
- if fill is None else fill)
1178
- # XY/lessR names droplines by the axis they reach (segments_y
1179
- # = to the y axis, i.e. horizontal); Chart names them by
1180
- # direction (segments_x = horizontal) -- so they swap
1181
- return Chart(
1182
- "row_names", y=val, data=d2, form="dot", horiz=y_row,
1183
- sort=sort, segments_x=segments_y, segments_y=segments_x,
1184
- fill=dot_fill, color=color, pt_size=pt_size, main=main,
1185
- xlab=xlab, ylab=ylab, digits_d=digits_d,
1186
- rotate_x=rotate_x, rotate_y=rotate_y,
1187
- axis_fmt=axis_fmt, quiet=quiet)
1199
+ f"{nm} applies to the dot plot of a categorical "
1200
+ "variable, which is Chart(form='dot'), not XY()")
1188
1201
 
1189
1202
  # ----- resolve variables and filter ---------------------------
1190
1203
  # ".Index" is the row-number pseudo-variable (1..n) for a run
@@ -1204,27 +1217,6 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1204
1217
  if index_y:
1205
1218
  y = "Index"
1206
1219
 
1207
- # stat= aggregates a numerical variable by a categorical one and
1208
- # shows a Cleveland dot plot -- delegate to Chart(form="dot"),
1209
- # its tested renderer (categorical variables live in Chart)
1210
- if stat is not None:
1211
- from .Chart import Chart
1212
- x_num = pd.api.types.is_numeric_dtype(x_ser)
1213
- y_num = pd.api.types.is_numeric_dtype(y_ser)
1214
- common = dict(
1215
- data=data, by=by, facet=facet, form="dot", stat=stat,
1216
- sort=sort, fill=fill, color=color,
1217
- transparency=transparency, pt_size=pt_size, main=main,
1218
- digits_d=digits_d, rotate_x=rotate_x, rotate_y=rotate_y,
1219
- axis_fmt=axis_fmt, quiet=quiet)
1220
- if not x_num and y_num: # categorical x, value y
1221
- return Chart(x, y=y, xlab=xlab, ylab=ylab, **common)
1222
- if x_num and not y_num: # value x, categorical y
1223
- return Chart(y, y=x, horiz=True, xlab=xlab, ylab=ylab,
1224
- **common)
1225
- raise ValueError(
1226
- "XY() with stat= needs one categorical and one "
1227
- "numerical variable (a Cleveland dot plot)")
1228
1220
  by_ser = get_column(data, by, "by") if by is not None else None
1229
1221
  (facet_ser, facet_name,
1230
1222
  facet2_ser, facet2_name) = resolve_facet(data, facet, "XY")
@@ -1268,33 +1260,40 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1268
1260
  pass
1269
1261
 
1270
1262
  is_date = pd.api.types.is_datetime64_any_dtype(x_ser)
1271
- # a categorical/continuous mix with facet= is X()'s display:
1272
- # redirect, as XY.R's Trellis check does
1273
- if facet is not None and not is_date:
1274
- x_num = pd.api.types.is_numeric_dtype(x_ser)
1275
- y_num = pd.api.types.is_numeric_dtype(y_ser)
1276
- if x_num != y_num:
1277
- cont, cat_ = (x, y) if x_num else (y, x)
1278
- f_txt = ("[" + ", ".join(f"'{f}'" for f in facet)
1279
- + "]"
1280
- if isinstance(facet, (list, tuple))
1281
- else f"'{facet}'" if isinstance(facet, str)
1282
- else f"'{facet_name}'")
1263
+ # XY() displays the relationship of two continuous variables; a
1264
+ # categorical x or y belongs to another view, so name its call
1265
+ # rather than render. R analog: XY.R master control funnel
1266
+ x_cat = not is_date and not pd.api.types.is_numeric_dtype(x_ser)
1267
+ y_cat = not pd.api.types.is_numeric_dtype(y_ser)
1268
+ if x_cat or y_cat:
1269
+ head = "XY() requires a continuous x and a continuous y.\n"
1270
+ if x_cat and y_cat:
1283
1271
  raise TypeError(
1284
- "XY() requires a continuous x and a continuous "
1285
- "y.\nFor a faceted distribution of the "
1286
- f"continuous {cont} across the levels\nof the "
1287
- f"categorical {cat_}, use X() with a by "
1288
- f"variable:\n X('{cont}', by='{cat_}', "
1289
- f"facet={f_txt})")
1290
- for nm, s in ((x, x_ser), (y, y_ser)):
1291
- if s is x_ser and is_date:
1292
- continue
1293
- if not pd.api.types.is_numeric_dtype(s):
1272
+ head + f"Both {x} and {y} are categorical.\n\n"
1273
+ "For two categorical variables, use Chart() with a "
1274
+ f"by variable:\n Chart('{x}', by='{y}', "
1275
+ "form='bubble')")
1276
+ cont, cat_, cat_s = ((y, x, x_ser) if x_cat
1277
+ else (x, y, y_ser))
1278
+ if not cat_s.dropna().duplicated().any():
1294
1279
  raise TypeError(
1295
- f"XY() analyzes the relationship of two numerical "
1296
- f"variables, but '{nm}' is {s.dtype}. For a "
1297
- "categorical variable use Chart().")
1280
+ head + f"{cat_} is categorical.\n\n"
1281
+ f"Each level of {cat_} identifies a single case, so\n"
1282
+ f" display the values of {cont} directly with "
1283
+ f"Chart():\n Chart('{cat_}', y='{cont}', "
1284
+ "form='dot')")
1285
+ f_txt = ""
1286
+ if facet is not None:
1287
+ f_txt = (", facet=[" + ", ".join(f"'{f}'" for f in facet)
1288
+ + "]" if isinstance(facet, (list, tuple))
1289
+ else f", facet='{facet}'"
1290
+ if isinstance(facet, str)
1291
+ else f", facet='{facet_name}'")
1292
+ raise TypeError(
1293
+ head + f"{cat_} is categorical.\n\n"
1294
+ f"For the distribution of the continuous {cont} across\n"
1295
+ f" the levels of the categorical {cat_}, use X() with "
1296
+ f"a by variable:\n X('{cont}', by='{cat_}'{f_txt})")
1298
1297
 
1299
1298
  # ts_NA= replaces missing y with a set value before the NA
1300
1299
  # rows are dropped, so a gap in the series becomes that value
@@ -1659,17 +1658,17 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1659
1658
 
1660
1659
  # ----- fit lines and SE bands ---------------------------------
1661
1660
  # with by= the bands default off, as in R (XY.R line ~484);
1662
- # plot_errors shows residuals, not a band, so it too defaults
1661
+ # fit_errors shows residuals, not a band, so it too defaults
1663
1662
  # the band off (XY.R line ~483)
1664
1663
  if fit_se is None:
1665
- fit_se = 0 if plot_errors else (0.95 if by is None else 0)
1664
+ fit_se = 0 if fit_errors else (0.95 if by is None else 0)
1666
1665
  se_levels = [lv for lv in (fit_se if isinstance(
1667
1666
  fit_se, (list, tuple)) else [fit_se]) if lv]
1668
1667
  if form == "contour": # R: no SE bands on contour
1669
1668
  se_levels = []
1670
1669
 
1671
1670
  fit_lines, se_polys, fit_stats = [], [], []
1672
- err_lines = [] # plot_errors residual segments
1671
+ err_lines = [] # fit_errors residual segments
1673
1672
  if fit != "off":
1674
1673
  for nm, xg, yg in groups:
1675
1674
  if len(xg) < 2:
@@ -1678,7 +1677,7 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1678
1677
  xs, ys_s, f, se_f = _loess(xg, yg, span)
1679
1678
  fit_lines.append({"name": nm, "x": xs, "y": f})
1680
1679
  fit_stats.append((nm, fit, ys_s, f))
1681
- if plot_errors:
1680
+ if fit_errors:
1682
1681
  err_lines.append((xs, ys_s, f))
1683
1682
  for lv in se_levels:
1684
1683
  tq = sps.t.ppf((1 + lv) / 2, len(xs) - 1)
@@ -1692,7 +1691,7 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1692
1691
  fit_lines.append({"name": nm, "x": xs[okf],
1693
1692
  "y": f[okf]})
1694
1693
  fit_stats.append((nm, fit, ys_s[okf], f[okf]))
1695
- if plot_errors:
1694
+ if fit_errors:
1696
1695
  err_lines.append((xs[okf], ys_s[okf], f[okf]))
1697
1696
  if fit == "lm":
1698
1697
  for lv in se_levels:
@@ -1845,7 +1844,8 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1845
1844
  y_lab=y if ylab is None else ylab,
1846
1845
  ax=ax, gridT1=gridT1, gridT2=ax["axT2"],
1847
1846
  main=main, digits_d=digits_d,
1848
- connect=is_date or show_runs or index_x, is_date=is_date,
1847
+ connect=is_date or show_runs or index_x, ln_width=line_width,
1848
+ is_date=is_date,
1849
1849
  pt_opacity=1 - transparency,
1850
1850
  area_polys=area_polys,
1851
1851
  fit_lines=fit_lines, fit_color=fit_color, fit_lwd=fit_lwd,
@@ -1866,9 +1866,9 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1866
1866
  # .Index run chart, or a runs chart keeps its wide aspect)
1867
1867
  fig.update_layout(**square_layout(main=bool(main)))
1868
1868
 
1869
- # plot_errors: residual segment from each point to its fitted
1869
+ # fit_errors: residual segment from each point to its fitted
1870
1870
  # value, drawn over the points (R plt.main.R ~1403, rgb 130,40,35)
1871
- if plot_errors and err_lines:
1871
+ if fit_errors and err_lines:
1872
1872
  ex, ey = [], []
1873
1873
  for xs_, yo_, ff_ in err_lines:
1874
1874
  for i in range(len(xs_)):
@@ -0,0 +1,195 @@
1
+ # bc_items_plotly.py — the multi-item stacked chart, Chart(x=[...]),
2
+ # for a set of items that share one response scale, such as Likert
3
+ # items. R analog: the multi-x path of Chart.R, its frequency table
4
+ # from .bc.main() (multi), drawn there with base graphics, and the
5
+ # faceted form .bar.stackedLattice(); this is the plotly design.
6
+ #
7
+ # Each item is one horizontal bar, divided into the counts of its
8
+ # responses in a divergent palette, so the bars compose the response
9
+ # distributions of all the items on one scale. Unfaceted, the items
10
+ # are ordered by their mean response, the largest at the top; each
11
+ # segment carries its percent of the item, omitted below 4%. With
12
+ # facet=, one panel per level, the items in the order given, first
13
+ # at the top, on one count scale (drawn by bc_facet_plotly).
14
+
15
+ import numpy as np
16
+ import pandas as pd
17
+ import plotly.graph_objects as go
18
+
19
+ from .utils import get_option, pretty
20
+ from .plotly_utils import (
21
+ as_plotly_color, auto_text_color, axis_cat, axis_format,
22
+ axis_num, legend_style, plot_border, plotly_style, to_hex,
23
+ x_grid,
24
+ )
25
+
26
+ # theme -> the two sequential palettes of the divergent fill
27
+ # R analog: .get_fill(theme, diverge=TRUE)
28
+ _DIVERGE = {
29
+ "gray": ("grays", "grays"), "white": ("grays", "grays"),
30
+ "darkred": ("turquoises", "reds"), "red": ("turquoises", "reds"),
31
+ "rose": ("turquoises", "reds"),
32
+ "slatered": ("turquoises", "reds"),
33
+ "darkgreen": ("violets", "greens"),
34
+ "green": ("violets", "greens"), "purple": ("violets", "greens"),
35
+ }
36
+
37
+
38
+ def items_fill(theme, n):
39
+ """The divergent palette of n response colors for a theme, the
40
+ low responses in the first hue. R analog: .color_range(
41
+ .get_fill(theme, diverge=TRUE), n)"""
42
+ from .getColors import getColors
43
+ p1, p2 = _DIVERGE.get(theme or "colors", ("browns", "blues"))
44
+ return [c[:7] for c in getColors(p1, p2, n=max(n, 2),
45
+ quiet=True)][:n]
46
+
47
+
48
+ def _resp_str(v):
49
+ if isinstance(v, float) and v.is_integer():
50
+ return str(int(v))
51
+ return str(v)
52
+
53
+
54
+ def items_table(cols, names):
55
+ """Responses x items table of counts over the union of the
56
+ response values, with the weighted mean response of each item.
57
+ Items that do not share one response set compose no single
58
+ chart: raise. R analog: .bc.main() multi tabulation and the
59
+ one_plot test of Chart.R"""
60
+ # one_plot: every item's responses fall within the largest set
61
+ sets = [set(c.dropna().unique()) for c in cols]
62
+ widest = max(sets, key=len)
63
+ if any(s - widest for s in sets):
64
+ raise ValueError(
65
+ "The x variables do not share one set of response "
66
+ "categories, so they do not compose a single multi-item "
67
+ "chart. Chart each variable separately.")
68
+ first = cols[0]
69
+ if isinstance(first.dtype, pd.CategoricalDtype):
70
+ resp = list(first.cat.categories)
71
+ for c in cols[1:]:
72
+ if isinstance(c.dtype, pd.CategoricalDtype):
73
+ resp += [v for v in c.cat.categories if v not in resp]
74
+ else:
75
+ vals = set()
76
+ for c in cols:
77
+ vals |= set(c.dropna().unique())
78
+ resp = sorted(vals)
79
+ frq = pd.DataFrame(
80
+ {nm: c.value_counts().reindex(resp, fill_value=0)
81
+ for nm, c in zip(names, cols)}, index=resp)
82
+ frq.index = [_resp_str(r) for r in resp]
83
+ numeric = all(isinstance(r, (int, float, np.integer, np.floating))
84
+ for r in resp)
85
+ wt = (np.array(resp, dtype=float) if numeric
86
+ else np.arange(1, len(resp) + 1, dtype=float))
87
+ with np.errstate(invalid="ignore", divide="ignore"):
88
+ wm = pd.Series((frq.to_numpy(dtype=float) * wt[:, None]).sum(0)
89
+ / frq.to_numpy(dtype=float).sum(0),
90
+ index=names)
91
+ return frq, wm, numeric
92
+
93
+
94
+ def items_stats_lines(frq, wm, title, numeric=True):
95
+ """'Frequencies of Responses by Variable': one row per item,
96
+ its counts and its mean. R analog: .prntbl(t(x), 0) with the
97
+ Mean column of .bc.main() multi"""
98
+ t = frq.T
99
+ rows = [str(r) for r in t.index]
100
+ max_c1 = max(len(r) for r in rows) + 2
101
+ widths = [max(4, max(len(str(c)),
102
+ max(len(f"{v:.0f}") for v in t[c])) + 1)
103
+ for c in t.columns]
104
+ mx = max(len(f"{round(v, 2):.15g}") for v in wm if np.isfinite(v))
105
+ hdr = " " * max_c1 + "".join(str(c).rjust(w)
106
+ for c, w in zip(t.columns, widths))
107
+ lines = [title, "", hdr + " " + "Mean".rjust(mx + 1)]
108
+ for r in t.index:
109
+ ln = (" " + str(r)).rjust(max_c1) + "".join(
110
+ f"{v:.0f}".rjust(w) for v, w in zip(t.loc[r], widths))
111
+ lines.append(ln + " " + f"{wm[r]:.3f}".rjust(mx + 1))
112
+ if not numeric:
113
+ lines += ["", "Computation of the mean based on coding "
114
+ "response categories from 1 to "
115
+ f"{len(frq.index)}"]
116
+ return lines + [""]
117
+
118
+
119
+ def bc_items_plotly(frq, order, fill, labels="%", labels_size=None,
120
+ labels_cut=0.04, stack100=False, x_lab="",
121
+ y_lab="", legend_title="Responses", main=None,
122
+ digits_d=0, axis_fmt="K", axis_x_pre="",
123
+ rotate_x=0, rotate_y=0, style_opts=None):
124
+ """One horizontal bar per item, stacked by response. frq:
125
+ responses x items counts; order: the items bottom to top."""
126
+ if style_opts is None:
127
+ style_opts = plotly_style()
128
+ cnt = frq[order].to_numpy(dtype=float)
129
+ tot = cnt.sum(axis=0)
130
+ with np.errstate(invalid="ignore", divide="ignore"):
131
+ share = np.where(tot > 0, cnt / tot, 0.0)
132
+ mat = share if stack100 else cnt
133
+ if labels_size is None:
134
+ labels_size = 0.8 - 0.008 * len(order)
135
+ txt_size = round(16 * labels_size)
136
+ fills = [as_plotly_color(f) for f in fill]
137
+ txt_cols = auto_text_color(fills, bg=style_opts["panel_fill"])
138
+
139
+ fig = go.Figure()
140
+ for i, r in enumerate(frq.index):
141
+ if labels == "off":
142
+ txt = None
143
+ else:
144
+ txt = [("" if s < labels_cut else
145
+ f"{100 * s:.0f}%" if labels == "%" else
146
+ f"{s:.2f}" if labels == "prop" else
147
+ f"{c:.0f}")
148
+ for s, c in zip(share[i], cnt[i])]
149
+ fig.add_trace(go.Bar(
150
+ x=mat[i], y=list(order), orientation="h", name=str(r),
151
+ marker=dict(color=fills[i % len(fills)],
152
+ line=dict(width=0)),
153
+ text=txt, textposition="inside",
154
+ insidetextanchor="middle", textangle=0,
155
+ textfont=dict(size=txt_size,
156
+ color=txt_cols[i % len(txt_cols)]),
157
+ customdata=np.stack([cnt[i], share[i]], axis=-1),
158
+ hovertemplate=(f"%{{y}}<br>{legend_title}: {r}"
159
+ "<br>Count: %{customdata[0]:.0f}"
160
+ "<br>% of item: %{customdata[1]:.1%}"
161
+ "<extra></extra>"),
162
+ ))
163
+
164
+ top = 1.0 if stack100 else float(np.nanmax(tot)) if tot.size else 1
165
+ axT = pretty(0.0, top)
166
+ if stack100:
167
+ d = 2
168
+ else:
169
+ d = digits_d
170
+ x_ax = axis_num(x_lab, axT, axis_format(axT, d, axis_fmt,
171
+ axis_x_pre))
172
+ x_ax.update(range=[0, axT[-1] * (1 if stack100 else 1.02)])
173
+ y_ax = axis_cat(y_lab)
174
+ y_ax.update(categoryorder="array", categoryarray=list(order))
175
+ if rotate_x:
176
+ x_ax["tickangle"] = -rotate_x
177
+ if rotate_y:
178
+ y_ax["tickangle"] = -rotate_y
179
+ leg = legend_style(legend_title, style_opts)
180
+ leg["traceorder"] = "normal"
181
+ fig.update_layout(
182
+ barmode="stack", bargap=0.2,
183
+ xaxis=x_ax, yaxis=y_ax,
184
+ shapes=x_grid(axT) + plot_border(),
185
+ legend=leg, template=None,
186
+ plot_bgcolor=to_hex(style_opts["panel_fill"]),
187
+ paper_bgcolor=to_hex(style_opts["window_fill"]),
188
+ )
189
+ if main:
190
+ title_size = round(16 * get_option("main_size", 1))
191
+ fig.update_layout(
192
+ title=dict(text=main, x=0.5, xanchor="center", y=0.98,
193
+ yanchor="top", font=dict(size=title_size)),
194
+ margin=dict(t=round(title_size * 2.2)))
195
+ return fig