lessPython 0.1.1__py3-none-any.whl → 0.3.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 (54) hide show
  1. lessPy/ANOVA.py +5 -5
  2. lessPy/Chart.py +1088 -154
  3. lessPy/Correlation.py +14 -3
  4. lessPy/Flows.py +5 -1
  5. lessPy/Logit.py +2 -2
  6. lessPy/Regression.py +6 -4
  7. lessPy/X.py +280 -43
  8. lessPy/XY.py +894 -238
  9. lessPy/__init__.py +12 -2
  10. lessPy/_themes.py +5007 -0
  11. lessPy/bc_items_plotly.py +208 -0
  12. lessPy/bc_plotly.py +116 -36
  13. lessPy/bubble_plotly.py +17 -2
  14. lessPy/corCFA.py +3 -3
  15. lessPy/corProp.py +2 -2
  16. lessPy/corReflect.py +2 -2
  17. lessPy/corReorder.py +2 -2
  18. lessPy/corScree.py +3 -3
  19. lessPy/data/StockPrice.csv +1401 -1386
  20. lessPy/details.py +17 -4
  21. lessPy/dn_plotly.py +66 -46
  22. lessPy/dot_plotly.py +112 -41
  23. lessPy/freq_poly_plotly.py +13 -6
  24. lessPy/getColors.py +253 -21
  25. lessPy/hier_plotly.py +84 -14
  26. lessPy/hs_plotly.py +19 -7
  27. lessPy/pie_plotly.py +17 -7
  28. lessPy/pivot.py +6 -3
  29. lessPy/plotly_utils.py +146 -23
  30. lessPy/plt_mat_plotly.py +74 -2
  31. lessPy/plt_plotly.py +15 -3
  32. lessPy/plt_time.py +11 -4
  33. lessPy/prob_norm.py +3 -3
  34. lessPy/prob_tcut.py +3 -3
  35. lessPy/prob_znorm.py +3 -1
  36. lessPy/profile_plotly.py +121 -0
  37. lessPy/rcolors.py +461 -0
  38. lessPy/simCImean.py +3 -3
  39. lessPy/simCLT.py +3 -3
  40. lessPy/simFlips.py +3 -3
  41. lessPy/simMeans.py +3 -3
  42. lessPy/stats_out.py +639 -38
  43. lessPy/style.py +160 -0
  44. lessPy/sunflower_plotly.py +140 -0
  45. lessPy/ttest.py +5 -5
  46. lessPy/utils.py +97 -20
  47. lessPy/vbs_plotly.py +31 -16
  48. lessPy/x_console.py +763 -0
  49. {lesspython-0.1.1.dist-info → lesspython-0.3.0.dist-info}/METADATA +9 -3
  50. lesspython-0.3.0.dist-info/RECORD +89 -0
  51. {lesspython-0.1.1.dist-info → lesspython-0.3.0.dist-info}/WHEEL +1 -1
  52. lesspython-0.1.1.dist-info/RECORD +0 -82
  53. {lesspython-0.1.1.dist-info → lesspython-0.3.0.dist-info}/licenses/LICENSE +0 -0
  54. {lesspython-0.1.1.dist-info → lesspython-0.3.0.dist-info}/top_level.txt +0 -0
lessPy/Chart.py CHANGED
@@ -28,16 +28,25 @@ import pandas as pd
28
28
  from pandas.api.types import is_numeric_dtype
29
29
 
30
30
  from .bc_plotly import bc_facet_plotly, bc_plotly
31
+ from .bc_items_plotly import (
32
+ bc_items_plotly, items_fill, items_stats_lines, items_table,
33
+ )
31
34
  from .plt_add import plt_add
32
35
  from .bubble_plotly import bubble_plotly
33
36
  from .dot_plotly import dot_plotly
34
37
  from .hier_plotly import (
35
- hier_aggregate, hier_color_resolve, hier_plotly,
38
+ hier_aggregate, hier_color_resolve, hier_color_resolve_byfac,
39
+ hier_plotly,
36
40
  )
37
41
  from .pie_plotly import pie_plotly
42
+ from .plt_plotly import plt_plotly
43
+ from .profile_plotly import profile_facet_plotly
38
44
  from .radar_plotly import radar_plotly
39
45
  from .plotly_utils import build_title, font_scaled
40
- from .stats_out import chart_stats, resolve_quiet
46
+ from .stats_out import (
47
+ CALL_STATS, ChartStats, attach_stats, chart_stats,
48
+ chart_stats_data, resolve_quiet,
49
+ )
41
50
  from .utils import (
42
51
  STAT_FUN, STAT_LBL, category_order as _category_order,
43
52
  facet_values, get_column as _get_column, get_option, pretty,
@@ -57,6 +66,36 @@ _THEME_PALETTE = {
57
66
  }
58
67
 
59
68
 
69
+ # each theme's single bar color, a bar chart without by drawn in it
70
+ # (R: style(theme)$bar$bar_fill_discrete, alpha from trans_bar_fill)
71
+ _THEME_BAR_FILL = {
72
+ "lightbronze": "#7B8C96", "dodgerblue": "#1874CDE6",
73
+ "darkred": "#8B0000E6", "gray": "#595959E6", "gold": "#CD9B1DE6",
74
+ "darkgreen": "#006400E6", "blue": "#4876FFE6", "red": "#CD2626E6",
75
+ "rose": "#CD9B9BE6", "green": "#006400E6", "purple": "#9B30FFE6",
76
+ "sienna": "#CD6839E6", "brown": "#8B6969E6", "orange": "#FFA500E6",
77
+ "white": "#FFFFFFE6", "slatered": "#A35763E6",
78
+ }
79
+
80
+
81
+ def _color_range(pal, n):
82
+ """n colors of a named palette, alpha dropped. Two grays are set
83
+ further apart than the palette spaces them, gray60 and gray30,
84
+ as in R. R analog: .color_range()"""
85
+ from .getColors import getColors
86
+ if pal == "grays" and n == 2:
87
+ return ["#999999", "#4D4D4D"]
88
+ cols = getColors(pal, n=max(1, n), quiet=True)
89
+ return [c[:7] if len(c) == 9 and c.endswith("FF") else c
90
+ for c in cols]
91
+
92
+
93
+ def _palette_names():
94
+ from .getColors import _SEQ_NAMES, _SEQ_R, _VIRIDIS
95
+ return set(_SEQ_NAMES) | set(_SEQ_R) | set(_VIRIDIS) | {
96
+ "hues", "Okabe-Ito", "Tableau", "distinct"}
97
+
98
+
60
99
  def _theme_fill(theme, n):
61
100
  """A theme's fill: an n-color sequential palette in the theme's
62
101
  hue. R analog: the theme branch of the fill assignment."""
@@ -75,9 +114,10 @@ def _facet_table(x_s, y_s, by_s, stat, is_agg, x_order, by_order,
75
114
  """Frequency or stat table for one facet level, on the global
76
115
  category set(s) so panels stay aligned; absent cells fill 0,
77
116
  as in R's xtabs. Series without by, DataFrame (by x cats)
78
- with. proportion normalizes the per-panel counts (no y, no by)
79
- to sum to 1, the faceted stat_x="proportion". Used by the
80
- faceted radar and bubble paths."""
117
+ with. proportion normalizes the per-panel counts to sum to 1,
118
+ the faceted stat_x="proportion": the whole panel without by,
119
+ each by row with it. Used by the faceted radar and bubble
120
+ paths."""
81
121
  if by_s is None:
82
122
  if y_s is None:
83
123
  t = x_s.groupby(x_s, observed=True).size() \
@@ -103,10 +143,15 @@ def _facet_table(x_s, y_s, by_s, stat, is_agg, x_order, by_order,
103
143
  t = STAT_FUN[stat](
104
144
  df.groupby(["by", "x"], observed=True)["y"]
105
145
  ).unstack("x")
106
- return (t.reindex(index=by_order, columns=x_order)
107
- .fillna(0))
108
-
109
- _FORMS = ("bar", "radar", "bubble", "dot", "pie", "icicle", "treemap")
146
+ t = t.reindex(index=by_order, columns=x_order).fillna(0)
147
+ if proportion and y_s is None:
148
+ # each group's distribution over x: every by row sums to 1
149
+ tot = t.sum(axis=1)
150
+ t = t.div(tot.where(tot != 0), axis=0).fillna(0)
151
+ return t
152
+
153
+ _FORMS = ("bar", "radar", "bubble", "dot", "pie", "icicle", "treemap",
154
+ "profile")
110
155
  _STATS = ("mean", "sum", "sd", "deviation", "min", "median", "max")
111
156
  _SORTS = ("0", "-", "+")
112
157
 
@@ -161,21 +206,394 @@ def _dot_origin_grid(vals, origin_in=None, is_counts=None):
161
206
  return origin, gridT
162
207
 
163
208
 
209
+ def _chart_items(data, items, y, by, facet, form, stat, sort,
210
+ sort_miss, stack100, horiz, fill, theme, labels,
211
+ labels_size, gap, labels_cut, xlab, ylab, main,
212
+ legend_title, power, radius, one_plot,
213
+ n_col, n_row, axis_fmt, axis_x_pre, rotate_x,
214
+ rotate_y, quiet):
215
+ """The multi-item stacked chart and its faceted form. R analog:
216
+ the multiple-x branch of Chart.R, .bc.main() multi, and
217
+ .bar.stackedLattice()"""
218
+ if by is not None:
219
+ raise ValueError(
220
+ "by is not available for multiple x variables: the "
221
+ "items claim position and the responses color, so a by "
222
+ "variable has no encoding left. Use facet, which draws "
223
+ "each group in a panel of its own.")
224
+ if facet is not None and form != "bar":
225
+ raise ValueError(
226
+ 'facet with multiple x variables requires form="bar"')
227
+ if form not in ("bar", "bubble"):
228
+ raise ValueError(
229
+ 'Multiple x variables only available for form="bar"')
230
+ if y is not None or stat is not None:
231
+ raise ValueError(
232
+ "A multi-item chart counts the responses to each item, "
233
+ "so y and stat do not apply")
234
+ cols = [_get_column(data, it, "x") for it in items]
235
+
236
+ # one_plot: the items compose one chart when they share one set of
237
+ # responses, decided from the data unless given; otherwise each
238
+ # variable is its own bar chart. R analog: Chart.R one_plot test
239
+ sets = [set(c.dropna().unique()) for c in cols]
240
+ widest = max(sets, key=len)
241
+ shared = not any(st - widest for st in sets)
242
+ if one_plot is None:
243
+ one_plot = shared
244
+ if not one_plot:
245
+ if facet is not None:
246
+ raise ValueError(
247
+ "facet requires the multi-item stacked chart, which "
248
+ "needs the x variables to share the same response "
249
+ "categories: a separate chart per variable is not a "
250
+ "single composition, so there are no panels to "
251
+ "condition")
252
+ if form != "bar":
253
+ raise ValueError(
254
+ 'A separate chart per variable is a bar chart, so it '
255
+ 'needs form="bar"')
256
+ return _chart_per_variable(data, items, cols, fill, xlab,
257
+ main, quiet)
258
+
259
+ frq, wm, numeric = items_table(cols, items)
260
+
261
+ if form == "bubble":
262
+ # the bubble plot frequency matrix: one row of bubbles per
263
+ # item, one column per response, each bubble sized by its
264
+ # count, the items in the order given from the bottom unless
265
+ # sorted by their mean. R analog: .dpmat.main()
266
+ order = list(items)
267
+ if sort != "0":
268
+ order = list(wm.sort_values(ascending=(sort == "+"),
269
+ kind="stable").index)
270
+ _set_stats(ChartStats(n_dim=2, freq=frq[order].T,
271
+ mean=wm[order]),
272
+ items_stats_lines(
273
+ frq[order], wm[order],
274
+ "Frequencies of Responses by Variable", numeric,
275
+ with_sum=True), quiet)
276
+ # the matrix lists its first row at the top, so reverse the
277
+ # order to draw the first item at the bottom, as R does
278
+ mat = frq[order[::-1]].T.astype(float)
279
+ mat.index.name, mat.columns.name = "Item", "Response"
280
+ cut = power / 2.5 * float(np.nanmax(mat.to_numpy()))
281
+ if fill is None:
282
+ fill = get_option("bar_fill_cont", "#96AAC3")
283
+ return bubble_plotly(
284
+ mat, x_name="Response", y_name="Count", by_name="Item",
285
+ x_lab="" if xlab is None else xlab,
286
+ y_lab="" if ylab is None else ylab,
287
+ main=main or None,
288
+ fill=(list(fill)[::-1] if isinstance(fill, (list, tuple))
289
+ else [fill] * len(order)),
290
+ border=get_option("pt_color", "#324E5C"),
291
+ opacity=0.6, power=power, radius=radius, digits_d=0,
292
+ labels="input" if labels is None else labels,
293
+ labels_color=get_option("bubble_text_color", "#F7F2E6"),
294
+ label_min_value=cut)
295
+ fill_use = (fill if isinstance(fill, (list, tuple))
296
+ else [fill] * len(frq.index) if fill is not None
297
+ else items_fill(theme, len(frq.index)))
298
+ leg = "Responses" if legend_title is None else legend_title
299
+ show = not resolve_quiet(quiet)
300
+
301
+ if facet is None:
302
+ # ordered by mean response, the largest at the top: the bars
303
+ # build upward from the bottom, so ascending unless sort
304
+ # says otherwise
305
+ if sort_miss:
306
+ sort = "+"
307
+ order = list(items)
308
+ if sort != "0":
309
+ order = list(wm.sort_values(ascending=(sort == "+"),
310
+ kind="stable").index)
311
+ # the table lists the items top of the chart first
312
+ _set_stats(ChartStats(n_dim=2, freq=frq[order[::-1]].T,
313
+ mean=wm[order[::-1]]),
314
+ _items_print(frq[order[::-1]], wm[order[::-1]],
315
+ numeric), quiet)
316
+ return bc_items_plotly(
317
+ frq, order, fill_use,
318
+ labels="%" if labels is None else labels,
319
+ labels_size=labels_size, stack100=stack100,
320
+ labels_cut=0.04 if labels_cut is None else labels_cut,
321
+ x_lab="" if xlab is None else xlab,
322
+ y_lab="" if ylab is None else ylab,
323
+ legend_title=leg, main=main or None,
324
+ axis_fmt=axis_fmt, axis_x_pre=axis_x_pre,
325
+ rotate_x=rotate_x, rotate_y=rotate_y, horiz=horiz)
326
+
327
+ # faceted: a panel is a fraction of the width of the single
328
+ # chart, too narrow for segment labels, and the paneled chart
329
+ # keeps the order given so the panels read alike
330
+ if labels is not None and labels != "off":
331
+ raise ValueError(
332
+ "labels are not drawn on a faceted multi-item chart. Each "
333
+ "panel is a fraction of the width of the single chart, so "
334
+ "its segments are too narrow for a legible label. The "
335
+ "counts for each panel are reported at the console.")
336
+ if gap is not None:
337
+ raise ValueError(
338
+ "gap is not available for a faceted multi-item chart")
339
+ if not sort_miss and sort != "0":
340
+ raise ValueError(
341
+ "sort is not available for a faceted multi-item chart: "
342
+ "the panels keep the items in the order given, so they "
343
+ "read alike")
344
+ if isinstance(facet, (list, tuple)):
345
+ fcols = [_get_column(data, f, "facet") for f in facet]
346
+ fac = fcols[0].astype(str)
347
+ for c in fcols[1:]:
348
+ fac = fac + " / " + c.astype(str)
349
+ facet_name = ", ".join(facet)
350
+ else:
351
+ fac = _get_column(data, facet, "facet")
352
+ facet_name = facet
353
+ keep = fac.notna()
354
+ facet_order = _category_order(fac[keep])
355
+ resp = list(frq.index)
356
+ tbls = {}
357
+ rep_lines, panels = [], {}
358
+ for lv in facet_order:
359
+ m = keep & (fac == lv)
360
+ f_lv, w_lv, _ = items_table([c[m] for c in cols], items) \
361
+ if all(c[m].notna().any() for c in cols) else (
362
+ pd.DataFrame(0, index=resp, columns=items), None, None)
363
+ f_lv = f_lv.reindex(index=resp, fill_value=0)
364
+ if w_lv is None:
365
+ w_lv = pd.Series(np.nan, index=items)
366
+ rep_lines += _items_print(
367
+ f_lv, w_lv, numeric,
368
+ f"Frequencies of Responses by Variable, "
369
+ f"{facet_name}: {lv}")
370
+ panels[lv] = ChartStats(freq=f_lv.T, mean=w_lv)
371
+ t = f_lv.astype(float)
372
+ if stack100:
373
+ tot = t.sum(axis=0)
374
+ t = t.div(tot.where(tot != 0), axis=1).fillna(0)
375
+ # first item at the top, or at the left of a vertical chart
376
+ tbls[lv] = t[items[::-1]] if horiz else t[items]
377
+ _set_stats(ChartStats(n_dim=3, panels=panels), rep_lines, quiet)
378
+ tbl = pd.DataFrame([tbls[lv].sum(axis=0) for lv in facet_order],
379
+ index=facet_order)
380
+ tbl.index.name = facet_name
381
+ n_col_use = (max(1, int(n_col)) if n_col is not None
382
+ else max(1, math.ceil(len(facet_order) / int(n_row)))
383
+ if n_row is not None else 1)
384
+ return bc_facet_plotly(
385
+ tbl, x_name="", facet_name=facet_name,
386
+ x_lab="" if xlab is None else xlab,
387
+ y_lab="" if ylab is None else ylab,
388
+ fill=fill_use, border="off",
389
+ digits_d=2 if stack100 else 0, n_col=n_col_use,
390
+ axis_fmt=axis_fmt, axis_x_pre=axis_x_pre,
391
+ rotate_x=rotate_x, rotate_y=rotate_y, main=main,
392
+ by_tbls=tbls, by_name=leg, beside=False,
393
+ val_name=("Proportion" if stack100 else "Count"),
394
+ legend_title=leg, horiz=horiz)
395
+
396
+
397
+ def _chart_per_variable(data, items, cols, fill, xlab, main, quiet):
398
+ """One bar chart of counts per categorical variable, as panels of
399
+ one figure, each followed at the console by its frequencies and
400
+ chi-square test. Numeric variables are passed over, as are a
401
+ variable that identifies each case and one with a single value.
402
+ R analog: bc.data.frame()"""
403
+ from plotly.subplots import make_subplots
404
+ import plotly.graph_objects as go
405
+ from .plotly_utils import (BASE_COLORS, auto_text_color,
406
+ axis_format, plotly_style, to_hex)
407
+ from .stats_out import _counts_1d
408
+ show = not resolve_quiet(quiet)
409
+ keep = []
410
+ for nm, c in zip(items, cols):
411
+ if is_numeric_dtype(c) and not isinstance(
412
+ c.dtype, pd.CategoricalDtype):
413
+ continue # R: .is.num.cat(x, 0) FALSE
414
+ v = c.dropna()
415
+ if v.nunique() >= len(v): # identifies each case
416
+ continue
417
+ if v.nunique() == 1:
418
+ if show:
419
+ print(f"\nVariable {nm} has only one value.\n"
420
+ " No bar chart produced.\n")
421
+ continue
422
+ keep.append((nm, c))
423
+ if not keep:
424
+ raise ValueError(
425
+ "No categorical variables, so no bar charts.\n\n"
426
+ "If you have integer variables that are categorical, "
427
+ "convert them to pandas Categorical, e.g.\n"
428
+ " d['Plan'] = d['Plan'].astype('category')")
429
+
430
+ n = len(keep)
431
+ n_col = min(3, n)
432
+ n_row = math.ceil(n / n_col)
433
+ fig = make_subplots(rows=n_row, cols=n_col,
434
+ subplot_titles=[nm for nm, _ in keep],
435
+ horizontal_spacing=0.08,
436
+ vertical_spacing=max(0.08, 0.3 / n_row))
437
+ st = plotly_style()
438
+ rep_lines, per_var = [], {}
439
+ for k, (nm, c) in enumerate(keep):
440
+ rep_lines += _counts_1d(c.dropna(), nm,
441
+ int(c.isna().sum())) + [""]
442
+ per_var[nm] = chart_stats_data(c.dropna(), None, None, None,
443
+ n_miss=int(c.isna().sum()))
444
+ order = _category_order(c.dropna())
445
+ cnt = c.value_counts().reindex(order, fill_value=0)
446
+ f = (fill if isinstance(fill, (list, tuple))
447
+ else [fill] if fill is not None else BASE_COLORS)
448
+ cols_k = [to_hex(f[i % len(f)]) for i in range(len(cnt))]
449
+ r, cc = k // n_col + 1, k % n_col + 1
450
+ fig.add_trace(go.Bar(
451
+ x=[str(v) for v in cnt.index], y=cnt.to_numpy(),
452
+ marker=dict(color=cols_k), text=[str(v) for v in cnt],
453
+ textposition="inside", showlegend=False,
454
+ textfont=dict(color=auto_text_color(cols_k)),
455
+ hovertemplate=f"{nm}: %{{x}}<br>Count: %{{y}}"
456
+ "<extra></extra>"), row=r, col=cc)
457
+ ticks = pretty(0.0, float(cnt.max()))
458
+ fig.update_yaxes(row=r, col=cc, tickvals=ticks,
459
+ ticktext=axis_format(ticks, 0),
460
+ range=[0, ticks[-1] * 1.04],
461
+ gridcolor="#E5E5E5",
462
+ title_text="Count" if cc == 1 else "")
463
+ fig.update_xaxes(row=r, col=cc, type="category",
464
+ title_text=xlab or "")
465
+ _set_stats(ChartStats(n_dim=1, variables=per_var), rep_lines,
466
+ quiet)
467
+ fig.update_layout(
468
+ template=None, height=140 + 260 * n_row,
469
+ plot_bgcolor=to_hex(st["panel_fill"]),
470
+ paper_bgcolor=to_hex(st["window_fill"]),
471
+ title=(dict(text=main, x=0.5) if main else None))
472
+ return fig
473
+
474
+
475
+ def _items_print(frq, wm, numeric, title=None):
476
+ return items_stats_lines(
477
+ frq, wm, title or "Frequencies of Responses by Variable",
478
+ numeric)
479
+
480
+
481
+ def _set_stats(st, lines, quiet):
482
+ """Keep the statistics of this call, with the report as their
483
+ text, and display the report unless quiet."""
484
+ st["text"] = "\n".join(lines)
485
+ CALL_STATS.set(st)
486
+ if not resolve_quiet(quiet):
487
+ print(st["text"])
488
+
489
+
490
+ def _profile_fill(theme):
491
+ """One hue per series from the theme, as R's profile does:
492
+ .color_range(.get_fill(theme), n), the qualitative hues for the
493
+ default theme."""
494
+ from .plotly_utils import BASE_COLORS
495
+ if theme is None or theme == "colors":
496
+ return list(BASE_COLORS)
497
+ return _theme_fill(theme, 8)
498
+
499
+
500
+ def _max_dd(vals):
501
+ """Most decimal digits among the first 200 values as R format()
502
+ writes them, 7 significant digits. R analog: .max.dd()"""
503
+ mx = 0
504
+ for v in list(vals)[:200]:
505
+ if v is None or not np.isfinite(v):
506
+ continue
507
+ txt = f"{float(v):.7g}"
508
+ if "." in txt and "e" not in txt:
509
+ mx = max(mx, len(txt) - txt.index(".") - 1)
510
+ return mx
511
+
512
+
513
+ def _dot_diff_lines(cats, ydf, x_name):
514
+ """The difference a paired dot plot presents, listed in the
515
+ order drawn, top of the plot first. R analog: the dif.pair
516
+ listing of Chart.R's paired dot path."""
517
+ difs = (ydf.iloc[:, 1] - ydf.iloc[:, 0]).to_numpy(dtype=float)
518
+ dd = min(_max_dd(np.r_[ydf.iloc[:, 0], ydf.iloc[:, 1]]) + 1, 7)
519
+ ny = len(difs)
520
+ mx_i = len(str(ny))
521
+ mx_d = max(len(f"{v:.{dd}f}") for v in difs)
522
+ mx_f = max([5] + [len(c) for c in cats])
523
+ lines = ["", f"{ydf.columns[1]} - {ydf.columns[0]}",
524
+ f"{'n'.rjust(mx_i)} {' diff'.rjust(mx_d)} {x_name}",
525
+ "-" * (mx_i + mx_d + mx_f + 2)]
526
+ shown = (range(1, ny + 1) if ny <= 20
527
+ else list(range(1, 11)) + list(range(ny - 9, ny + 1)))
528
+ for i in range(1, ny + 1):
529
+ k = ny - i
530
+ if i in shown:
531
+ lines.append(f"{str(i).rjust(mx_i)} "
532
+ f"{f'{difs[k]:.{dd}f}'.rjust(mx_d)} {cats[k]}")
533
+ return lines + [""]
534
+
535
+
536
+ def _dot_paired(cats, ydf, sort, sort_miss, origin_x, show_diff,
537
+ x_name, quiet=None, **render):
538
+ """Order a multi-series dot plot and draw it. Two series are a
539
+ pair, read for the gap between them, so by default they are
540
+ ordered by that difference, ascending so the largest positive
541
+ difference is drawn at the top, and the value axis begins at
542
+ zero. More series are ordered by their row mean when sort is
543
+ given. R analog: Chart.R paired dot path."""
544
+ dif_pair = ydf.shape[1] == 2
545
+ if dif_pair and sort_miss:
546
+ sort = "+"
547
+ if sort != "0":
548
+ key = (ydf.iloc[:, 1] - ydf.iloc[:, 0] if dif_pair
549
+ else ydf.mean(axis=1))
550
+ order = np.argsort(key.to_numpy(dtype=float)
551
+ * (-1 if sort == "-" else 1),
552
+ kind="stable")
553
+ cats = [cats[i] for i in order]
554
+ ydf = ydf.iloc[order]
555
+ if dif_pair:
556
+ # the difference the display presents, kept with the figure; it
557
+ # is reported where R lists it (show_diff), unless quiet
558
+ dif = pd.Series(
559
+ (ydf.iloc[:, 1] - ydf.iloc[:, 0]).to_numpy(dtype=float),
560
+ index=cats, name=f"{ydf.columns[1]} - {ydf.columns[0]}")
561
+ st = CALL_STATS.get()
562
+ if st is None:
563
+ st = ChartStats(n_dim=2, values=ydf.set_axis(cats), text="")
564
+ CALL_STATS.set(st)
565
+ st["diff"] = dif
566
+ if show_diff:
567
+ lines = _dot_diff_lines(cats, ydf, x_name)
568
+ st["text"] = "\n".join(
569
+ [t for t in (st.get("text", ""), "\n".join(lines)) if t])
570
+ if not resolve_quiet(quiet):
571
+ print("\n".join(lines))
572
+ origin, gridT = _dot_origin_grid(
573
+ ydf.to_numpy(dtype=float).ravel(),
574
+ origin_in=0 if (dif_pair and origin_x is None) else origin_x)
575
+ if origin is None:
576
+ origin = 0
577
+ return dot_plotly(cats, ydf, gridT=gridT, origin_x=origin,
578
+ **render)
579
+
580
+
164
581
  def Chart(x, y=None, data=None, filter=None, by=None, facet=None,
165
582
  form="bar",
166
583
  n_row=None, n_col=None,
167
- hole=0.65,
584
+ hole=0.62,
168
585
  radius=0.50, power=0.5,
169
586
  pt_size=1, origin_x=None, origin_y=None,
170
587
  segments_x=None, segments_y=None,
171
588
  stat=None, stat_x="count",
172
- horiz=False, sort="0", beside=False, stack100=False,
589
+ horiz=None, sort=None, beside=False, stack100=False,
173
590
  gap=None, scale_y=None, break_x=None,
174
591
  fill=None, color=None, transparency=None,
175
592
  fill_split=None, fill_scaled=False, fill_chroma=75,
176
593
  theme=None,
177
594
  labels=None, labels_position=None,
178
595
  labels_color=None, labels_size=None, labels_decimals=None,
596
+ labels_cut=None, segments=None, one_plot=None,
179
597
  legend_title=None, legend_position=None,
180
598
  legend_labels=None, legend_horiz=False,
181
599
  legend_size=None, legend_abbrev=None, legend_adjust=0,
@@ -216,8 +634,26 @@ def Chart(x, y=None, data=None, filter=None, by=None, facet=None,
216
634
  "K": "60K" for thousands, commas past 9999); axis_x_pre /
217
635
  axis_y_pre prepend a per-axis prefix (e.g. "$").
218
636
 
637
+ form="dot" is Cleveland's dot plot: categories on the vertical
638
+ axis (horiz defaults to True for this form alone), the value
639
+ axis from zero, no title unless main= is given. With by=, one
640
+ series of dots per level; with exactly two levels, or a pair
641
+ of y variables (y=["Pre", "Post"]), the pair is joined by a
642
+ segment and ordered by its difference, largest positive at
643
+ the top. sort="0" keeps the data order instead.
644
+
645
+ form="profile" plots one point per category of x and connects
646
+ the points across the categories; with by= it draws one
647
+ profile per level, the interaction plot of a two-way ANOVA.
648
+ origin_y sets where the value axis begins; facet= draws one
649
+ panel per level on a shared value scale.
650
+
219
651
  Variables are strings naming columns of the DataFrame `data`.
220
652
  Returns a plotly Figure; call .show() to display from a script.
653
+ The statistics behind the chart come with it as fig.stats, read
654
+ as attributes or by name: fig.stats.freq, fig.stats.p_value,
655
+ fig.stats.text (the console report, kept when quiet=True), named
656
+ as R's Chart() names its returned list.
221
657
  """
222
658
 
223
659
  # ----- validate parameters ------------------------------------
@@ -226,6 +662,62 @@ def Chart(x, y=None, data=None, filter=None, by=None, facet=None,
226
662
  if form not in _FORMS:
227
663
  raise ValueError(f"form must be one of {_FORMS}")
228
664
 
665
+ # Cleveland's dot plot places the categories on the vertical
666
+ # axis so their labels read across. A horizontal chart lists
667
+ # its first category at the bottom, so sort is inverted to keep
668
+ # "-" meaning largest at the top. R analog: Chart.R horiz.miss
669
+ sort_miss = sort is None
670
+ if sort is None:
671
+ sort = "0"
672
+ multi_x = isinstance(x, (list, tuple))
673
+ if horiz is None:
674
+ # the dot plot and the multi-item chart list their categories
675
+ # down the vertical axis
676
+ horiz = form == "dot" or multi_x
677
+ if horiz and sort in ("+", "-"):
678
+ sort = "+" if sort == "-" else "-"
679
+
680
+ # segments joins the points of a profile; the dot chart's stems
681
+ # are set along its value axis
682
+ if segments is not None and form != "profile":
683
+ raise ValueError(
684
+ "segments joins the points of a profile chart, so it "
685
+ 'needs form="profile"; for the segments of a dot chart '
686
+ "set segments_x or segments_y")
687
+ if labels_cut is not None and form != "bar":
688
+ raise ValueError(
689
+ "labels_cut leaves off the value labels of small bar "
690
+ 'segments, so it needs form="bar"')
691
+
692
+ # the value axis of a profile is y, so origin_x has nothing to set
693
+ if form == "profile" and origin_x is not None:
694
+ raise ValueError(
695
+ "origin_x does not apply to a profile chart: its value "
696
+ "axis is y, so set origin_y")
697
+
698
+ # Only the segments parameter along the value axis applies. The
699
+ # dot plot's orientation decides which one that is, so say which
700
+ # parameter to set instead rather than let the given one go
701
+ # inert. An unfaceted plot of several series is always
702
+ # horizontal.
703
+ # R analog: Chart.R .seg_note
704
+ if form == "dot":
705
+ dot_h = horiz or (facet is None and (
706
+ by is not None or isinstance(y, (list, tuple))))
707
+ given, instead, cat_ax, seg_ax = (
708
+ ("segments_y", "segments_x", "y", "x") if dot_h
709
+ else ("segments_x", "segments_y", "x", "y"))
710
+ if (segments_y if dot_h else segments_x) is not None:
711
+ print(f">>> {given} does not apply to this dot plot.\n"
712
+ f" The categories are on the {cat_ax}-axis, so "
713
+ "the line segments run along the "
714
+ f"{seg_ax}-axis.\n"
715
+ f" To control them, set {instead}.\n")
716
+ if dot_h:
717
+ segments_y = None
718
+ else:
719
+ segments_x = None
720
+
229
721
  # hierarchical routing: treemap/icicle always; pie with by=
230
722
  # nests the by rings inside the x wedges as a sunburst
231
723
  # R analog: Chart.R hier dispatch
@@ -234,14 +726,33 @@ def Chart(x, y=None, data=None, filter=None, by=None, facet=None,
234
726
  hier_type = form
235
727
  elif form == "pie" and by is not None:
236
728
  hier_type = "sunburst"
729
+ if isinstance(by, (list, tuple)) and len(by) > 1 \
730
+ and hier_type is None:
731
+ raise ValueError(
732
+ f"Only one by variable is permitted for a {form} chart, "
733
+ "but more than one specified.\n\n"
734
+ "The groups of by are overlaid within the display, one "
735
+ "color\n to each. That one channel is carried by the "
736
+ "first variable,\n so a second has no encoding left by "
737
+ "which to separate its\n levels.\n"
738
+ "Multiple by variables apply only to the hierarchical\n"
739
+ " charts -- pie/sunburst, treemap, and icicle -- where\n"
740
+ " nesting accepts depth and each added variable is one\n"
741
+ " level deeper.\n"
742
+ f"To stratify a {form} chart by a second variable, use\n"
743
+ " facet, which draws each group in a panel of its own.")
744
+ # hierarchical forms: each further by variable is one level
745
+ # deeper; the first plays the part of a single by elsewhere
746
+ by_more = []
747
+ if isinstance(by, (list, tuple)):
748
+ by_more = list(by[1:])
749
+ by = by[0] if by else None
750
+ by_label = ", ".join([by] + by_more) if by is not None else None
237
751
  if hier_type is not None and stat == "deviation":
238
752
  raise ValueError('stat="deviation" is not meaningful for '
239
753
  "hierarchical charts: negative values "
240
754
  "cannot form part-of-whole areas")
241
755
 
242
- if form == "dot" and by is not None:
243
- raise ValueError("The by variable is not meaningful for "
244
- "dot charts. Do a bar chart.")
245
756
  if isinstance(y, (list, tuple)) and form != "dot":
246
757
  raise ValueError('Multiple y variables are only supported '
247
758
  'for form="dot" (paired dot chart)')
@@ -256,15 +767,22 @@ def Chart(x, y=None, data=None, filter=None, by=None, facet=None,
256
767
  raise ValueError('labels_position must be "in" or "out"')
257
768
  if axis_fmt not in ("K", ",", ".", ""):
258
769
  raise ValueError('axis_fmt must be "K", ",", "." or ""')
770
+ # proportions of a bar chart with by are taken within each bar,
771
+ # the 100% stacked chart. R analog: .bc.main()
772
+ # if (!is.null(by) && prop) stack100 <- TRUE
773
+ if (stat_x == "proportion" and by is not None and form == "bar"
774
+ and facet is None and y is None):
775
+ stack100 = True
776
+ stat_x = "count"
259
777
  if stack100:
260
778
  if form != "bar":
261
779
  raise ValueError('stack100 rescales the bars of a bar '
262
780
  'chart, so it needs form="bar"')
263
- if facet is not None:
781
+ if facet is not None and by is None:
264
782
  raise ValueError(
265
- "stack100 rescales each bar within its by groups, "
266
- "and a faceted bar chart (Trellis) has no by "
267
- "variable. Use by= instead of facet=.")
783
+ "stack100 rescales each bar to show how the levels "
784
+ "of a by variable divide it, so a faceted bar chart "
785
+ "needs by= for it")
268
786
  if by is None and stat_x == "proportion":
269
787
  raise ValueError(
270
788
  'stack100 without a by variable is the same as '
@@ -291,6 +809,10 @@ def Chart(x, y=None, data=None, filter=None, by=None, facet=None,
291
809
  "legend_abbrev": legend_abbrev,
292
810
  "legend_adjust": legend_adjust or None}
293
811
  _leg_set = [k for k, v in _leg.items() if v is not None]
812
+ # a dot plot of several series heads its legend with legend_title
813
+ if form == "dot" and (by is not None
814
+ or isinstance(y, (list, tuple))):
815
+ _leg_set = [k for k in _leg_set if k != "legend_title"]
294
816
  if _leg_set:
295
817
  if form != "bar":
296
818
  raise ValueError(
@@ -300,7 +822,7 @@ def Chart(x, y=None, data=None, filter=None, by=None, facet=None,
300
822
  raise ValueError(
301
823
  f"{', '.join(_leg_set)} style the legend that a by "
302
824
  "variable creates, so they need by=")
303
- if facet is not None:
825
+ if facet is not None and _leg_set != ["legend_title"]:
304
826
  raise ValueError(
305
827
  f"{', '.join(_leg_set)} apply to a single-panel bar "
306
828
  "chart; a faceted (Trellis) bar chart has no by "
@@ -348,62 +870,88 @@ def Chart(x, y=None, data=None, filter=None, by=None, facet=None,
348
870
  if sort != "0":
349
871
  raise ValueError("Sort not applicable to Trellis "
350
872
  "(faceted bar) charts")
351
- if stat is not None:
352
- raise ValueError(
353
- "Only the original data work with Trellis "
354
- "plots, no data aggregation with parameter "
355
- "stat. Use by instead of facet.")
356
- if y is not None:
357
- raise ValueError(
358
- "The faceted bar chart displays counts of x, "
359
- "so a y variable does not apply. Use by "
360
- "instead of facet.")
361
- if by is not None:
362
- raise ValueError(
363
- "by and facet do not combine for bar charts: "
364
- "each facet panel displays the counts of x. "
365
- "Use one or the other.")
366
873
 
367
874
  # ----- resolve variables and filter ---------------------------
368
875
  if filter is not None:
369
876
  data = data.query(filter)
370
877
 
371
- # paired dot chart: x=labels, y=[col1, col2, ...], displayed
372
- # directly, never aggregated. R analog: Chart.R paired-dot path
878
+ # row_names: the data frame's row labels as the categorical x,
879
+ # in their own order rather than alphabetical; the axis label
880
+ # is dropped unless given. R analog: Chart.R row_names
881
+ if (isinstance(x, str) and x in ("row_names", "row.names")
882
+ and x not in data.columns):
883
+ names = data.index.astype(str)
884
+ data = data.copy()
885
+ data[x] = pd.Categorical(
886
+ names, categories=list(dict.fromkeys(names)))
887
+ if xlab is None:
888
+ xlab = ""
889
+
890
+ # a list of x variables that share one response scale: the
891
+ # multi-item stacked chart. Its bands claim position and color
892
+ # the responses within them, so by has no encoding left; facet
893
+ # panels remain. R analog: Chart.R multiple-x path
894
+ if multi_x:
895
+ return _chart_items(
896
+ data, list(x), y=y, by=by, facet=facet, form=form,
897
+ stat=stat, sort=sort, sort_miss=sort_miss,
898
+ stack100=stack100, horiz=horiz, fill=fill, theme=theme,
899
+ labels=labels, labels_size=labels_size, gap=gap,
900
+ labels_cut=labels_cut, power=power, radius=radius,
901
+ one_plot=one_plot,
902
+ xlab=xlab, ylab=ylab, main=main,
903
+ legend_title=legend_title, n_col=n_col, n_row=n_row,
904
+ axis_fmt=axis_fmt, axis_x_pre=axis_x_pre,
905
+ rotate_x=rotate_x, rotate_y=rotate_y, quiet=quiet)
906
+
907
+ # paired dot chart: x=labels, y=[col1, col2, ...]. Displayed
908
+ # directly when x identifies the cases; with a repeated x each
909
+ # column is aggregated over x by stat, which is then required.
910
+ # R analog: Chart.R paired-dot path and its multi-column y stat
373
911
  if isinstance(y, (list, tuple)):
374
- if stat is not None:
375
- raise ValueError("stat does not apply to a paired dot "
376
- "chart; the values display directly")
377
912
  x_ser = _get_column(data, x, "x")
378
913
  y_cols = [_get_column(data, yi, "y") for yi in y]
379
914
  sub = pd.concat([x_ser] + y_cols, axis=1).dropna()
380
- cats = sub[x].astype(str).tolist()
381
915
  ydf = sub[list(y)]
382
-
383
- # sort= orders rows by row mean across all series
384
- if sort != "0":
385
- order = (ydf.mean(axis=1)
386
- .sort_values(ascending=(sort == "+")).index)
387
- cats = sub.loc[order, x].astype(str).tolist()
388
- ydf = ydf.loc[order]
389
-
390
- origin, gridT = _dot_origin_grid(
391
- ydf.to_numpy(dtype=float).ravel(), origin_in=origin_x)
392
- if origin is None:
393
- origin = 0
394
- if main is None:
395
- main = build_title(x, y_name=" & ".join(y))
396
- elif main == "":
397
- main = None
398
- return dot_plotly(
399
- cats, ydf,
400
- pt_size=pt_size,
401
- x_lab="" if xlab is None else xlab, y_lab=x,
916
+ y_lbl = " & ".join(y)
917
+ if sub[x].duplicated().any():
918
+ if stat is None:
919
+ raise ValueError(
920
+ "The data are not a summary (pivot) table, and "
921
+ f"you have numerical variables, y = {y_lbl}, so "
922
+ "specify stat to define the aggregation, e.g., "
923
+ 'stat="mean"')
924
+ if stat not in STAT_FUN and stat != "deviation":
925
+ raise ValueError(f"stat must be one of {_STATS}")
926
+ grp = ydf.groupby(sub[x], observed=True, sort=True)
927
+ if stat == "deviation":
928
+ ydf = grp.mean()
929
+ ydf = ydf - ydf.mean()
930
+ else:
931
+ ydf = STAT_FUN[stat](grp)
932
+ ydf = ydf.reindex([c for c in _category_order(sub[x])
933
+ if c in ydf.index])
934
+ cats = [str(c) for c in ydf.index]
935
+ val_lab = f"{STAT_LBL[stat]} of {y_lbl}"
936
+ else:
937
+ if stat is not None:
938
+ raise ValueError(
939
+ "The data are a summary table, so do not specify "
940
+ "stat: the aggregation has already been done")
941
+ cats = sub[x].astype(str).tolist()
942
+ val_lab = y_lbl
943
+ x_lab_dot = (xlab if xlab else ylab if ylab else val_lab)
944
+ return _dot_paired(
945
+ cats, ydf.reset_index(drop=True), sort, sort_miss,
946
+ origin_x, show_diff=True, quiet=quiet, x_name=x,
947
+ fill=fill, border=color, pt_size=pt_size,
948
+ x_lab=x_lab_dot, y_lab=x,
402
949
  digits_d=2 if digits_d is None else digits_d,
403
950
  pt_opacity=1 - (get_option("trans_pt_fill", 0.10)
404
951
  if transparency is None
405
952
  else transparency),
406
- gridT=gridT, origin_x=origin, main=main,
953
+ main=None if not main else main,
954
+ legend_title=legend_title,
407
955
  axis_fmt=axis_fmt, axis_x_pre=axis_x_pre,
408
956
  rotate_x=rotate_x, rotate_y=rotate_y,
409
957
  segments_x=(True if segments_x is None
@@ -412,6 +960,7 @@ def Chart(x, y=None, data=None, filter=None, by=None, facet=None,
412
960
 
413
961
  x_ser = _get_column(data, x, "x")
414
962
  by_ser = _get_column(data, by, "by") if by is not None else None
963
+ by_more_sers = [_get_column(data, b, "by") for b in by_more]
415
964
  y_ser = _get_column(data, y, "y") if y is not None else None
416
965
 
417
966
  # multiple facet variables define the panel grid, not extra
@@ -431,11 +980,17 @@ def Chart(x, y=None, data=None, filter=None, by=None, facet=None,
431
980
  fac_cols = [ser]
432
981
  facet_name = str(ser.name)
433
982
 
983
+ # count missing x before the removal below, or it is always 0
984
+ # R analog: Chart.R n.miss.x
985
+ n_miss_x = int(x_ser.isna().sum())
986
+
434
987
  # casewise deletion over the variables in the analysis
435
988
  used = [s for s in (x_ser, by_ser, y_ser) if s is not None]
989
+ used += by_more_sers
436
990
  used += fac_cols or []
437
991
  keep = ~pd.concat(used, axis=1).isna().any(axis=1)
438
992
  x_ser = x_ser[keep]
993
+ by_more_sers = [b[keep] for b in by_more_sers]
439
994
  if by_ser is not None:
440
995
  by_ser = by_ser[keep]
441
996
  if y_ser is not None:
@@ -451,15 +1006,67 @@ def Chart(x, y=None, data=None, filter=None, by=None, facet=None,
451
1006
 
452
1007
  x_order = _category_order(x_ser)
453
1008
  by_order = _category_order(by_ser) if by_ser is not None else None
1009
+
1010
+ # A numeric x with many distinct values yields one category per
1011
+ # value, usually a distribution in disguise. Advisory only: no
1012
+ # count of levels reliably separates categorical from
1013
+ # continuous, so the call is honored. R analog: Chart.R n.x > 12
1014
+ if (y_ser is None and pd.api.types.is_numeric_dtype(x_ser)
1015
+ and len(x_order) > 12):
1016
+ print(f">>> {x} is numeric with {len(x_order)} distinct "
1017
+ "values, so each\n becomes its own category. For "
1018
+ "the distribution of a continuous\n variable: "
1019
+ f'X("{x}")\n')
454
1020
  facet_order = (_category_order(facet_ser)
455
1021
  if facet_ser is not None else None)
456
1022
 
457
- # theme=: a sequential palette in the theme's hue over the fill
458
- # elements (the by groups if present, else the x categories)
459
- if theme is not None and fill is None:
460
- n_fill = len(by_order) if by_order is not None \
461
- else len(x_order)
462
- fill = _theme_fill(theme, n_fill)
1023
+ # a theme set by style(), or by this call's theme=, which the
1024
+ # wrapper has applied for the call
1025
+ if theme is None:
1026
+ theme = get_option("theme")
1027
+
1028
+ # an ordered category is ordinal, so its default fill is the
1029
+ # theme's sequential palette, light to dark in the order of the
1030
+ # levels, not distinct hues. Fill encodes x without by and by
1031
+ # with it, so the ordering of that variable is the one that
1032
+ # applies. R analog: Chart.R is.ordered() fill
1033
+ if (form == "bar" and fill is None and facet_ser is None
1034
+ and fill_split is None and not fill_scaled):
1035
+ enc = by_ser if by_ser is not None else x_ser
1036
+ if (isinstance(enc.dtype, pd.CategoricalDtype)
1037
+ and enc.cat.ordered):
1038
+ fill = _color_range(
1039
+ _THEME_PALETTE.get(theme, "blues"),
1040
+ len(by_order) if by_order is not None
1041
+ else len(x_order))
1042
+
1043
+ # theme=: a bar chart without by is drawn in the theme's single
1044
+ # bar color, which fill_scaled then varies in luminance; with
1045
+ # by, the levels span the theme's sequential palette, darkest
1046
+ # first. The default theme keeps the qualitative hues.
1047
+ # R analog: Chart.R fill block (theme.seq) and .bc.main()
1048
+ if (theme is not None and fill is None
1049
+ and theme not in ("colors", "hues")):
1050
+ if theme not in _THEME_PALETTE:
1051
+ raise ValueError(
1052
+ f"unknown theme '{theme}'; one of: "
1053
+ f"{', '.join(sorted(_THEME_PALETTE))}")
1054
+ if by_order is not None:
1055
+ fill = list(reversed(_color_range(_THEME_PALETTE[theme],
1056
+ len(by_order))))
1057
+ elif form == "bar":
1058
+ fill = [_THEME_BAR_FILL.get(theme, "#595959E6")] \
1059
+ * len(x_order)
1060
+ else:
1061
+ fill = _theme_fill(theme, len(x_order))
1062
+
1063
+ # a palette name in fill, such as "reds", names a range of colors,
1064
+ # one per bar, or per level of by. R analog: .bc.main()
1065
+ # .color_range(fill, n.levels)
1066
+ if (form == "bar" and isinstance(fill, str) and not fill_scaled
1067
+ and fill in _palette_names()):
1068
+ fill = _color_range(fill, len(by_order) if by_order is not None
1069
+ else len(x_order))
463
1070
 
464
1071
  # explicit panel-grid layout: n_col wins, else derive from
465
1072
  # n_row; None keeps each renderer's own default (single-column
@@ -523,6 +1130,10 @@ def Chart(x, y=None, data=None, filter=None, by=None, facet=None,
523
1130
  n_x = x_ser.nunique()
524
1131
  if by_ser is None:
525
1132
  is_agg = n_x >= len(x_ser)
1133
+ elif by_more_sers:
1134
+ # each cell of the nesting appears once in a summary table
1135
+ cells = pd.concat([x_ser, by_ser] + by_more_sers, axis=1)
1136
+ is_agg = not cells.astype(str).duplicated().any()
526
1137
  else:
527
1138
  is_agg = n_x * by_ser.nunique() >= len(by_ser)
528
1139
 
@@ -551,36 +1162,75 @@ def Chart(x, y=None, data=None, filter=None, by=None, facet=None,
551
1162
  f"have a numerical variable y='{y}', so specify "
552
1163
  'stat to define the aggregation, e.g., stat="mean"')
553
1164
 
554
- # accompanying statistics: frequencies, or the stat of y
555
- if not resolve_quiet(quiet):
556
- y_out = (y_ser if y_ser is not None
557
- and stat in STAT_FUN else None)
558
- if y_ser is None or y_out is not None:
559
- print("\n".join(chart_stats(
560
- x_ser, by_ser, y_out, stat, x, by, y,
561
- 2 if digits_d is None else digits_d)))
562
-
563
- # labels default for aggregated data; for counts leave labels
564
- # None so bc_plotly shows the value with % lines in hover
565
- # R analog: Chart.R line ~716
1165
+ # accompanying statistics: frequencies, or the stat of y. They are
1166
+ # computed whether or not displayed, and returned with the figure
1167
+ # as fig.stats, as R's Chart() returns its list
1168
+ y_out = (y_ser if y_ser is not None
1169
+ and stat in STAT_FUN else None)
1170
+ if by_more_sers and (y_ser is None or y_out is not None):
1171
+ from .stats_out import nested_stats
1172
+ lines = nested_stats(
1173
+ [x_ser, by_ser] + by_more_sers, [x, by] + by_more,
1174
+ y_out, stat, y, 2 if digits_d is None else digits_d)
1175
+ cols_n = [x_ser, by_ser] + by_more_sers
1176
+ df_n = pd.DataFrame({nm: c for nm, c in
1177
+ zip([x, by] + by_more, cols_n)})
1178
+ _set_stats(ChartStats(
1179
+ n_dim=len(cols_n),
1180
+ freq=df_n.groupby([x, by] + by_more, observed=True).size()
1181
+ if y_out is None else None),
1182
+ lines, quiet)
1183
+ elif y_ser is None or y_out is not None:
1184
+ var_lbl = data.attrs.get("variable_labels", {}) or {}
1185
+ lines = chart_stats(
1186
+ x_ser, by_ser, y_out, stat, x, by, y,
1187
+ 2 if digits_d is None else digits_d,
1188
+ stack100=stack100, n_miss=n_miss_x,
1189
+ x_lbl=var_lbl.get(x), y_lbl=var_lbl.get(y),
1190
+ facet_ser=facet_ser, facet_name=facet_name,
1191
+ form=form)
1192
+ _set_stats(chart_stats_data(x_ser, by_ser, y_out, stat,
1193
+ facet_ser=facet_ser,
1194
+ n_miss=n_miss_x),
1195
+ lines, quiet)
1196
+
1197
+ # labels default for aggregated data, the value of the statistic;
1198
+ # bars of counts are labeled with their percentages, as lessR's
1199
+ # do (getOption("values"), "%"): of the total, or of each bar for
1200
+ # the 100% stacked chart. R analog: Chart.R ~716, .bc.main()
566
1201
  if labels is None and (stat is not None or
567
1202
  (is_agg and y_ser is not None)):
568
- labels = "%" if beside else "input"
1203
+ # the bars carry a statistic, so the label is its value;
1204
+ # beside only arranges the bars, and a percentage of a sum
1205
+ # of means is not a quantity
1206
+ labels = "input"
1207
+ elif labels is None and form == "bar" and y_ser is None:
1208
+ labels = "%"
569
1209
 
570
1210
  # ----- hierarchical forms aggregate per nesting level ---------
571
1211
  # from the raw columns, not from the flat table built below
572
1212
  if hier_type is not None:
573
1213
  if digits_d is None:
574
1214
  digits_d = 0 if y_ser is None else 2
575
- agg = hier_aggregate(x_ser, by=by_ser, y=y_ser, stat=stat,
1215
+ agg = hier_aggregate(x_ser,
1216
+ by=([by_ser] + by_more_sers if by_more
1217
+ else by_ser),
1218
+ y=y_ser, stat=stat,
576
1219
  facet=facet_ser,
577
- x_name=x, by_name=by, y_name=y,
1220
+ x_name=x,
1221
+ by_name=[by] + by_more if by_more else by,
1222
+ y_name=y,
578
1223
  facet_name=facet_name,
579
1224
  facet_order=facet_order)
580
- fill_vec = hier_color_resolve(x_order, fill)
1225
+ fill_vec = hier_color_resolve(x_order, fill, x=x_ser,
1226
+ y=y_ser, stat=stat)
1227
+ fill_vec_byfac = (None if facet_ser is None else
1228
+ hier_color_resolve_byfac(
1229
+ x_order, fill, x_ser, y_ser, stat,
1230
+ facet_ser, facet_order))
581
1231
  if main is None:
582
- main = build_title(x, by_name=by, y_name=y, stat=stat,
583
- facet_name=facet_name)
1232
+ main = build_title(x, by_name=by_label, y_name=y,
1233
+ stat=stat, facet_name=facet_name)
584
1234
  elif main == "":
585
1235
  main = None
586
1236
  # Chart resolves the labels default before hier sees it:
@@ -590,6 +1240,7 @@ def Chart(x, y=None, data=None, filter=None, by=None, facet=None,
590
1240
  # keep the inner rings meaningful.
591
1241
  return hier_plotly(
592
1242
  agg, fill_vec, type=hier_type,
1243
+ fill_vec_byfac=fill_vec_byfac,
593
1244
  x_name=x, by_name=by, facet_name=facet_name,
594
1245
  main=main, border=color, digits_d=digits_d,
595
1246
  labels="%" if labels is None else labels,
@@ -604,32 +1255,95 @@ def Chart(x, y=None, data=None, filter=None, by=None, facet=None,
604
1255
  # (hier forms handled above; pie facet became the by grouping)
605
1256
 
606
1257
  if facet_ser is not None and form == "bar":
607
- # Trellis bar chart: horizontal count bars per panel,
608
- # the plotly port of R's .bar.lattice rendering
609
- tbl = (pd.crosstab(facet_ser, x_ser)
610
- .reindex(index=facet_order, columns=x_order,
611
- fill_value=0))
1258
+ # Trellis bar chart: horizontal bars per panel, the plotly
1259
+ # port of R's .bar.lattice rendering. Counts of x, or the
1260
+ # stat of y aggregated within each panel; a by variable
1261
+ # divides each bar into its levels, as the bar chart of a
1262
+ # single panel does, drawn within every panel
1263
+ agg_y = y_ser is not None and stat is not None
1264
+ if y_ser is not None and not agg_y and not is_agg:
1265
+ raise ValueError(
1266
+ "The data are not a summary (pivot) table, so "
1267
+ 'specify stat to define the aggregation, e.g., '
1268
+ 'stat="mean"')
1269
+
1270
+ def cell_tbl(m):
1271
+ xs = x_ser[m]
1272
+ if by_ser is None:
1273
+ if y_ser is None:
1274
+ t = xs.groupby(xs, observed=True).size()
1275
+ elif agg_y:
1276
+ t = STAT_FUN[stat](y_ser[m].groupby(xs,
1277
+ observed=True))
1278
+ else:
1279
+ t = y_ser[m].groupby(xs, observed=True).first()
1280
+ return t.reindex(x_order)
1281
+ df = pd.DataFrame({"b": by_ser[m], "x": xs,
1282
+ "y": 1.0 if y_ser is None
1283
+ else y_ser[m].astype(float)})
1284
+ g = df.groupby(["b", "x"], observed=True)["y"]
1285
+ t = (g.sum() if y_ser is None
1286
+ else STAT_FUN[stat](g) if agg_y else g.first())
1287
+ t = t.unstack("x").reindex(index=by_order,
1288
+ columns=x_order)
1289
+ if y_ser is None:
1290
+ t = t.fillna(0)
1291
+ if stat_x == "proportion" and not stack100:
1292
+ # every cell of the panel as a share of the panel,
1293
+ # as lattice's prop.table(margin=panel)
1294
+ tot = t.to_numpy().sum()
1295
+ t = t / tot if tot > 0 else t
1296
+ if stack100:
1297
+ # each bar scaled to its own total compares
1298
+ # composition rather than magnitude
1299
+ tot = t.sum(axis=0)
1300
+ t = t.div(tot.where(tot != 0), axis=1)
1301
+ return t
1302
+
1303
+ tbls = {lv: cell_tbl(facet_ser == lv) for lv in facet_order}
1304
+ if by_ser is None:
1305
+ tbl = pd.DataFrame([tbls[lv] for lv in facet_order],
1306
+ index=facet_order)
1307
+ by_tbls = None
1308
+ else:
1309
+ # the panel totals stand in for the shape of the grid
1310
+ tbl = pd.DataFrame([tbls[lv].sum(axis=0)
1311
+ for lv in facet_order],
1312
+ index=facet_order)
1313
+ by_tbls = tbls
612
1314
  tbl.index.name = facet_name
613
1315
  tbl.columns.name = x
1316
+ if stack100:
1317
+ val_name = f"Proportion within {x}"
1318
+ elif agg_y:
1319
+ val_name = f"{STAT_LBL[stat]} of {y}"
1320
+ elif y_ser is not None:
1321
+ val_name = y
1322
+ elif by_ser is not None:
1323
+ # named for x, as lattice's "Count of" / "Proportion of"
1324
+ val_name = ("Proportion of " if stat_x == "proportion"
1325
+ else "Count of ") + x
1326
+ else:
1327
+ val_name = None # Count/Proportion of x
614
1328
  return bc_facet_plotly(
615
1329
  tbl, x_name=x, facet_name=facet_name,
616
1330
  x_lab=xlab, y_lab=ylab, fill=fill,
617
1331
  border="off" if color is None else color,
618
1332
  opacity=(None if transparency is None
619
1333
  else 1 - transparency),
620
- proportion=stat_x == "proportion",
1334
+ proportion=stat_x == "proportion" and by_ser is None,
621
1335
  digits_d=(digits_d if digits_d is not None
622
- else (0 if stat_x == "count" else 2)),
1336
+ else 2 if (y_ser is not None or stack100
1337
+ or stat_x != "count") else 0),
623
1338
  n_col=n_col_use or 1,
624
1339
  axis_fmt=axis_fmt, axis_x_pre=axis_x_pre,
625
1340
  rotate_x=rotate_x, rotate_y=rotate_y,
626
1341
  main=main,
1342
+ by_tbls=by_tbls, by_name=by, beside=beside,
1343
+ val_name=val_name, legend_title=legend_title,
627
1344
  )
628
1345
 
629
1346
  if facet_ser is not None and form == "radar":
630
- if stat_x == "proportion" and by_ser is not None:
631
- raise NotImplementedError(
632
- 'stat_x="proportion" with by= is not yet ported')
633
1347
  facets = {
634
1348
  str(lv): _facet_table(
635
1349
  x_ser[facet_ser == lv],
@@ -707,54 +1421,154 @@ def Chart(x, y=None, data=None, filter=None, by=None, facet=None,
707
1421
  n_col=n_col_use,
708
1422
  )
709
1423
 
1424
+ if facet_ser is not None and form == "profile":
1425
+ # one profile per group within every panel, on the categories
1426
+ # and value scale all panels share. Without y the value is a
1427
+ # count; with y and no stat the values are supplied directly,
1428
+ # one per cell. R analog: Chart.R faceted profile, drawn by
1429
+ # .plt.profile.facet() in base R; here a plotly grid
1430
+ agg_y = not is_agg and stat is not None
1431
+ if y_ser is not None and not agg_y and not is_agg:
1432
+ raise ValueError(
1433
+ "The data are not a summary (pivot) table, so "
1434
+ 'specify stat to define the aggregation, e.g., '
1435
+ 'stat="mean"')
1436
+ series = ([str(b) for b in by_order] if by_ser is not None
1437
+ else [None])
1438
+ x_lv = [str(c) for c in x_order]
1439
+ pos = {c: k + 1 for k, c in enumerate(x_lv)}
1440
+ df = pd.DataFrame({
1441
+ "f": facet_ser.astype(str), "x": x_ser.astype(str),
1442
+ "g": (by_ser.astype(str) if by_ser is not None
1443
+ else "\0"),
1444
+ "y": 1.0 if y_ser is None else y_ser.astype(float)})
1445
+ g = df.groupby(["f", "g", "x"], observed=True)["y"]
1446
+ t = (g.sum() if y_ser is None
1447
+ else STAT_FUN[stat](g) if agg_y else g.mean())
1448
+ if y_ser is None and stat_x == "proportion":
1449
+ # each series is its group's distribution over x in that
1450
+ # panel; without by, the panel's own distribution
1451
+ t = t / t.groupby(level=["f", "g"]).transform("sum")
1452
+ cells = {}
1453
+ for (f, gg, xx), v in t.items():
1454
+ nm = None if by_ser is None else gg
1455
+ xs, ys = cells.setdefault(f, {}).setdefault(nm, ([], []))
1456
+ xs.append(pos[xx])
1457
+ ys.append(float(v))
1458
+ for f in cells: # draw left to right
1459
+ for nm, (xs, ys) in cells[f].items():
1460
+ o = np.argsort(xs)
1461
+ cells[f][nm] = ([xs[k] for k in o], [ys[k] for k in o])
1462
+ vals = t.to_numpy(dtype=float)
1463
+ lo, hi = float(np.nanmin(vals)), float(np.nanmax(vals))
1464
+ # the panels share one value scale, so the origin widens it
1465
+ if origin_y is not None:
1466
+ lo, hi = min(lo, origin_y), max(hi, origin_y)
1467
+ y_tick = pretty(lo, hi)
1468
+ if digits_d is None:
1469
+ digits_d = 0 if y_ser is None else 2
1470
+ from .plotly_utils import axis_format
1471
+ y_lab = (ylab if ylab is not None
1472
+ else f"Proportion of {x}" if (y_ser is None
1473
+ and stat_x == "proportion")
1474
+ else "Count" if y_ser is None
1475
+ else f"{STAT_LBL[stat]} of {y}" if agg_y else y)
1476
+ if y_ser is None and stat_x == "proportion" and digits_d == 0:
1477
+ digits_d = 2
1478
+ return profile_facet_plotly(
1479
+ cells, x_lv, series, y_tick,
1480
+ axis_format(y_tick, digits_d, axis_fmt, axis_y_pre),
1481
+ connect=True if segments is None else bool(segments),
1482
+ x_lab=x if xlab is None else xlab, y_lab=y_lab,
1483
+ by_name=by,
1484
+ fill=(fill if isinstance(fill, (list, tuple))
1485
+ else [fill] if fill is not None
1486
+ else _profile_fill(theme)),
1487
+ pt_size=1.5 * pt_size,
1488
+ facet_levels=[str(f) for f in facet_order],
1489
+ facet_name=facet_name, n_col=n_col_use,
1490
+ main=None if not main else main, rotate_x=rotate_x)
1491
+
710
1492
  if facet_ser is not None and form == "dot":
711
1493
  # aggregate and sort per facet panel; a panel shows only
712
- # its own categories. R analog: Chart.R faceted dot prep
1494
+ # its own categories. With by=, one series per level of by in
1495
+ # every panel, on the full x-by-panel grid so a combination
1496
+ # absent from the data is an empty cell. R analog: Chart.R
1497
+ # faceted dot prep and its by reshape
713
1498
  prop = stat_x == "proportion"
1499
+ agg_y = not is_agg and stat is not None
714
1500
  cats_l, vals_l, fac_l = [], [], []
715
- for lv in facet_order:
716
- m = facet_ser == lv
717
- xs = x_ser[m].astype(str)
718
- if y_ser is None: # counts per panel
719
- t = xs.groupby(xs).size()
720
- if prop:
721
- tot = t.sum()
722
- t = t / tot if tot > 0 else t.astype(float)
723
- elif not is_agg and stat is not None:
724
- t = STAT_FUN[stat](y_ser[m].groupby(xs))
725
- else: # pre-aggregated rows
726
- t = pd.Series(y_ser[m].to_numpy(dtype=float),
727
- index=xs.values)
728
- if sort != "0":
729
- t = t.sort_values(ascending=(sort == "+"))
730
- cats_l += [str(c) for c in t.index]
731
- vals_l += [float(v) for v in t.to_numpy()]
732
- fac_l += [str(lv)] * len(t)
733
-
734
- val_lab = (y if y is not None
735
- else ("Proportion of " + x if prop
736
- else f"Count of {x}"))
1501
+ if by_ser is not None:
1502
+ rows = []
1503
+ for lv in facet_order:
1504
+ m = facet_ser == lv
1505
+ df = pd.DataFrame({"x": x_ser[m].astype(str),
1506
+ "by": by_ser[m].astype(str),
1507
+ "y": (1.0 if y_ser is None
1508
+ else y_ser[m].astype(float))})
1509
+ g = df.groupby(["x", "by"], observed=True)["y"]
1510
+ t = (g.sum() if y_ser is None
1511
+ else STAT_FUN[stat](g) if agg_y else g.first())
1512
+ wide = (t.unstack("by")
1513
+ .reindex(index=[str(c) for c in x_order],
1514
+ columns=[str(b) for b in by_order]))
1515
+ if prop and y_ser is None:
1516
+ # each group's distribution over x in this panel
1517
+ tot = wide.sum(axis=0)
1518
+ wide = wide.div(tot.where(tot != 0), axis=1)
1519
+ rows.append(wide)
1520
+ cats_l += list(wide.index)
1521
+ fac_l += [str(lv)] * len(wide)
1522
+ vals_l = pd.concat(rows, ignore_index=True)
1523
+ all_vals = vals_l.to_numpy(dtype=float).ravel()
1524
+ else:
1525
+ for lv in facet_order:
1526
+ m = facet_ser == lv
1527
+ xs = x_ser[m].astype(str)
1528
+ if y_ser is None: # counts per panel
1529
+ t = xs.groupby(xs).size()
1530
+ if prop:
1531
+ tot = t.sum()
1532
+ t = t / tot if tot > 0 else t.astype(float)
1533
+ elif agg_y:
1534
+ t = STAT_FUN[stat](y_ser[m].groupby(xs))
1535
+ else: # pre-aggregated rows
1536
+ t = pd.Series(y_ser[m].to_numpy(dtype=float),
1537
+ index=xs.values)
1538
+ if sort != "0":
1539
+ t = t.sort_values(ascending=(sort == "+"),
1540
+ kind="stable")
1541
+ cats_l += [str(c) for c in t.index]
1542
+ vals_l += [float(v) for v in t.to_numpy()]
1543
+ fac_l += [str(lv)] * len(t)
1544
+ all_vals = vals_l
1545
+
1546
+ # an aggregated value is labeled by its statistic, as the
1547
+ # unfaceted chart is: "Mean of Salary", not "Salary"
1548
+ if y_ser is None:
1549
+ val_lab = (f"Proportion of {x}" if prop
1550
+ else "Count" if by_ser is not None
1551
+ else f"Count of {x}")
1552
+ else:
1553
+ val_lab = f"{STAT_LBL[stat]} of {y}" if agg_y else y
737
1554
  if horiz:
738
1555
  orientation = "h"
739
- x_lab_arg = xlab if xlab is not None else val_lab
740
- y_lab_arg = ylab if ylab is not None else x
1556
+ x_lab_arg = (xlab if xlab is not None
1557
+ else ylab if ylab is not None else val_lab)
1558
+ y_lab_arg = x
741
1559
  else:
742
1560
  orientation = "v"
743
1561
  x_lab_arg = xlab if xlab is not None else x
744
1562
  y_lab_arg = ylab if ylab is not None else val_lab
745
1563
 
1564
+ # the length of the segment carries the value, so the value
1565
+ # axis begins at zero unless an origin is specified
1566
+ org_in = origin_x if horiz else origin_y
746
1567
  origin, gridT = _dot_origin_grid(
747
- vals_l,
748
- origin_in=origin_x if horiz else origin_y,
1568
+ all_vals, origin_in=0 if org_in is None else org_in,
749
1569
  is_counts=True if y_ser is None else None)
750
1570
  if digits_d is None:
751
1571
  digits_d = 2 if (y_ser is not None or prop) else 0
752
- if main is None:
753
- main = build_title(
754
- x, y_name="Proportion" if prop else y,
755
- stat=stat, facet_name=facet_name)
756
- elif main == "":
757
- main = None
758
1572
  return dot_plotly(
759
1573
  cats_l, vals_l, orientation=orientation,
760
1574
  fill=fill, border=color, pt_size=pt_size,
@@ -763,7 +1577,10 @@ def Chart(x, y=None, data=None, filter=None, by=None, facet=None,
763
1577
  pt_opacity=1 - (get_option("trans_pt_fill", 0.10)
764
1578
  if transparency is None
765
1579
  else transparency),
766
- gridT=gridT, origin_x=origin, main=main,
1580
+ gridT=gridT, origin_x=origin,
1581
+ main=None if not main else main,
1582
+ legend_title=(legend_title if legend_title is not None
1583
+ else by),
767
1584
  facet=fac_l, facet_name=facet_name, n_col=n_col_use,
768
1585
  axis_fmt=axis_fmt, axis_x_pre=axis_x_pre,
769
1586
  axis_y_pre=axis_y_pre,
@@ -780,9 +1597,6 @@ def Chart(x, y=None, data=None, filter=None, by=None, facet=None,
780
1597
 
781
1598
  # ----- build the table the renderer receives ------------------
782
1599
  if y_ser is None: # counts of x
783
- if stat_x == "proportion" and by_ser is not None:
784
- raise NotImplementedError(
785
- 'stat_x="proportion" with by= is not yet ported')
786
1600
 
787
1601
  if by_ser is None:
788
1602
  tbl = (x_ser.groupby(x_ser).size()
@@ -802,11 +1616,20 @@ def Chart(x, y=None, data=None, filter=None, by=None, facet=None,
802
1616
  tbl = pd.crosstab(by_ser, x_ser)
803
1617
  tbl = tbl.reindex(index=by_order, columns=x_order,
804
1618
  fill_value=0)
805
- y_name = "Count"
1619
+ if stat_x == "proportion":
1620
+ # each group's distribution over x, so the groups are
1621
+ # compared whatever their sizes: every by row sums
1622
+ # to 1 (the bar chart took its own path above, the
1623
+ # 100% stacked chart within each bar)
1624
+ tot = tbl.sum(axis=1)
1625
+ tbl = tbl.div(tot.where(tot != 0), axis=0).fillna(0)
1626
+ y_name = "Proportion"
1627
+ else:
1628
+ y_name = "Count"
806
1629
  if ylab is None:
807
- ylab = f"Count of {x}"
1630
+ ylab = f"{y_name} of {x}"
808
1631
  if digits_d is None:
809
- digits_d = 0
1632
+ digits_d = 0 if stat_x == "count" else 2
810
1633
 
811
1634
  elif stat == "deviation": # R analog: .agg()
812
1635
  if by_ser is not None:
@@ -838,7 +1661,11 @@ def Chart(x, y=None, data=None, filter=None, by=None, facet=None,
838
1661
  else:
839
1662
  tbl = fun(df.groupby(["by", "x"])["y"]).unstack("x")
840
1663
  tbl = tbl.reindex(index=by_order, columns=x_order)
841
- if not np.isfinite(tbl.to_numpy(dtype=float)).all():
1664
+ # a profile or dot plot of several groups draws an empty
1665
+ # cell as a missing point; the other forms need every cell
1666
+ gaps_ok = form in ("profile", "dot") and by_ser is not None
1667
+ if not gaps_ok and not np.isfinite(
1668
+ tbl.to_numpy(dtype=float)).all():
842
1669
  raise ValueError(
843
1670
  "The summary table of the transformed data has "
844
1671
  "missing or non-finite values, likely because some "
@@ -882,7 +1709,9 @@ def Chart(x, y=None, data=None, filter=None, by=None, facet=None,
882
1709
  elif beside:
883
1710
  ylab = "Percentage"
884
1711
  else:
885
- ylab = f"Cell % within {x} by {by}"
1712
+ # the axis holds proportions, so it is named for them;
1713
+ # the legend names by, so it is not repeated here
1714
+ ylab = f"Proportion within {x}"
886
1715
  if digits_d_user is None:
887
1716
  digits_d = 2
888
1717
 
@@ -925,6 +1754,7 @@ def Chart(x, y=None, data=None, filter=None, by=None, facet=None,
925
1754
  labels_decimals=labels_decimals,
926
1755
  labels_size=0.90 if labels_size is None
927
1756
  else labels_size,
1757
+ shares=(y_ser is None and stat_x == "proportion"),
928
1758
  )
929
1759
 
930
1760
  if form == "radar":
@@ -954,32 +1784,122 @@ def Chart(x, y=None, data=None, filter=None, by=None, facet=None,
954
1784
  digits_d=digits_d, val_label=val_label,
955
1785
  )
956
1786
 
1787
+ if form == "profile":
1788
+ # one point per category of x, connected across the
1789
+ # categories, one profile per level of by: the interaction
1790
+ # plot of the analysis of variance. The connecting segments
1791
+ # are the purpose of the form. R analog: Chart.R profile ->
1792
+ # .plt.main(cat.x=TRUE, segments=TRUE) -> plt.plotly()
1793
+ if isinstance(tbl, pd.DataFrame):
1794
+ groups = [(str(b), tbl.columns, tbl.loc[b])
1795
+ for b in tbl.index]
1796
+ else:
1797
+ groups = [(None, tbl.index, tbl)]
1798
+ cats = [str(c) for c in groups[0][1]]
1799
+ pos = list(range(1, len(cats) + 1))
1800
+ # an empty cell stays NaN, which plotly draws as a gap
1801
+ groups = [(nm, pos, vals.to_numpy(dtype=float).tolist())
1802
+ for nm, _, vals in groups]
1803
+ vals = tbl.to_numpy(dtype=float).ravel()
1804
+ lo, hi = float(np.nanmin(vals)), float(np.nanmax(vals))
1805
+ # the value axis of a profile is y, so origin_y sets where
1806
+ # it begins, as it does for the dot chart
1807
+ if origin_y is not None:
1808
+ lo, hi = min(lo, origin_y), max(hi, origin_y)
1809
+ axT2 = pretty(lo, hi)
1810
+ from .plotly_utils import axis_format
1811
+ y_lab = (ylab_user if ylab_user is not None
1812
+ else "Count" if y_ser is None and stat_x == "count"
1813
+ else ylab)
1814
+ fig = plt_plotly(
1815
+ groups, by_name=by,
1816
+ fill=(fill if isinstance(fill, (list, tuple))
1817
+ else [fill] if fill is not None
1818
+ else _profile_fill(theme)),
1819
+ pt_size=1.5 * pt_size,
1820
+ x_lab=x if xlab is None else xlab, y_lab=y_lab,
1821
+ ax={"axT1": pos, "axL1": cats, "axT2": axT2,
1822
+ "axL2": axis_format(axT2, digits_d, axis_fmt,
1823
+ axis_y_pre)},
1824
+ gridT1=pos, gridT2=axT2,
1825
+ # no automatic title: it would only repeat the axis labels
1826
+ main=None if not main else main,
1827
+ digits_d=digits_d,
1828
+ connect=True if segments is None else bool(segments),
1829
+ pt_opacity=1,
1830
+ style_opts=None)
1831
+ pad = 0.04 * (axT2[-1] - axT2[0])
1832
+ fig.update_yaxes(range=[axT2[0] - pad, axT2[-1] + pad])
1833
+ fig.update_xaxes(range=[0.5, len(cats) + 0.5])
1834
+ if rotate_x:
1835
+ fig.update_xaxes(tickangle=-rotate_x)
1836
+ if rotate_y:
1837
+ fig.update_yaxes(tickangle=-rotate_y)
1838
+ return fig
1839
+
957
1840
  if form == "dot":
958
- # tbl is always a Series here (by= was rejected above)
1841
+ quiet_use = resolve_quiet(quiet)
1842
+ pt_op = 1 - (get_option("trans_pt_fill", 0.10)
1843
+ if transparency is None else transparency)
1844
+ if isinstance(tbl, pd.DataFrame):
1845
+ # by=: one series of dots per level of by, drawn as the
1846
+ # paired geometry, one column per level. A two-level by
1847
+ # with a stat already lists its difference as the Diff
1848
+ # row of the summary table. R analog: Chart.R dot by
1849
+ # reshape into the paired path
1850
+ ydf = tbl.T.reindex([c for c in x_order if c in tbl.columns])
1851
+ ydf.columns = [str(c) for c in ydf.columns]
1852
+ cats = [str(c) for c in ydf.index]
1853
+ val_lab = (xlab if xlab else ylab_user if ylab_user
1854
+ else ylab if (y_ser is not None
1855
+ or stat_x == "proportion")
1856
+ else "Count")
1857
+ return _dot_paired(
1858
+ cats, ydf.reset_index(drop=True), sort, sort_miss,
1859
+ origin_x,
1860
+ show_diff=y_ser is None, quiet=quiet,
1861
+ x_name=x,
1862
+ fill=fill, border=color, pt_size=pt_size,
1863
+ x_lab=val_lab, y_lab=x, digits_d=digits_d,
1864
+ pt_opacity=pt_op,
1865
+ main=None if not main else main,
1866
+ legend_title=(legend_title if legend_title is not None
1867
+ else by),
1868
+ axis_fmt=axis_fmt, axis_x_pre=axis_x_pre,
1869
+ rotate_x=rotate_x, rotate_y=rotate_y,
1870
+ segments_x=(True if segments_x is None
1871
+ else segments_x),
1872
+ )
1873
+
959
1874
  cats = [str(c) for c in tbl.index]
960
1875
  vals = tbl.to_numpy(dtype=float)
1876
+ # the length of the segment carries the value, so the value
1877
+ # axis begins at zero unless an origin is specified
1878
+ org_in = origin_x if horiz else origin_y
961
1879
  origin, gridT = _dot_origin_grid(
962
- vals,
963
- origin_in=origin_x if horiz else origin_y,
1880
+ vals, origin_in=0 if org_in is None else org_in,
964
1881
  is_counts=True if y_ser is None else None)
965
- cat_lab = x if xlab is None else xlab
966
1882
  val_lab = ylab # set with tbl above
967
- if main is None:
968
- main = build_title(x, y_name=y_name, stat=stat)
969
- elif main == "":
970
- main = None
1883
+ if horiz:
1884
+ # the value axis takes the plotted quantity, from xlab or
1885
+ # the statistic; the category axis takes x's own name
1886
+ x_lab_arg = xlab if xlab is not None else val_lab
1887
+ y_lab_arg = x
1888
+ else:
1889
+ x_lab_arg = xlab if xlab is not None else x
1890
+ y_lab_arg = val_lab
1891
+ # the value axis already names the plotted quantity, so an
1892
+ # unrequested title would only repeat it
971
1893
  return dot_plotly(
972
1894
  cats, vals,
973
1895
  orientation="h" if horiz else "v",
974
1896
  fill=fill, border=color,
975
1897
  pt_size=pt_size,
976
- x_lab=val_lab if horiz else cat_lab,
977
- y_lab=cat_lab if horiz else val_lab,
1898
+ x_lab=x_lab_arg, y_lab=y_lab_arg,
978
1899
  digits_d=digits_d,
979
- pt_opacity=1 - (get_option("trans_pt_fill", 0.10)
980
- if transparency is None
981
- else transparency),
982
- gridT=gridT, origin_x=origin, main=main,
1900
+ pt_opacity=pt_op,
1901
+ gridT=gridT, origin_x=origin,
1902
+ main=None if not main else main,
983
1903
  axis_fmt=axis_fmt, axis_x_pre=axis_x_pre,
984
1904
  axis_y_pre=axis_y_pre,
985
1905
  rotate_x=rotate_x, rotate_y=rotate_y,
@@ -1019,8 +1939,16 @@ def Chart(x, y=None, data=None, filter=None, by=None, facet=None,
1019
1939
  if (fill_split is not None or fill_scaled) \
1020
1940
  and isinstance(tbl, pd.Series):
1021
1941
  from .getColors import bar_fill_colors
1022
- fill = bar_fill_colors(tbl.to_numpy(dtype=float), fill,
1023
- fill_split, fill_scaled, fill_chroma)
1942
+ # one fill repeated over the bars is a single color; more
1943
+ # than two leave the hue to the theme, as R's .bc.main()
1944
+ f_in = fill
1945
+ if isinstance(fill, (list, tuple)):
1946
+ uniq = list(dict.fromkeys(fill))
1947
+ f_in = (uniq[0] if len(uniq) == 1
1948
+ else None if len(uniq) > 2 else uniq)
1949
+ fill = bar_fill_colors(tbl.to_numpy(dtype=float), f_in,
1950
+ fill_split, fill_scaled, fill_chroma,
1951
+ theme=theme)
1024
1952
 
1025
1953
  fig = bc_plotly(
1026
1954
  tbl,
@@ -1041,6 +1969,11 @@ def Chart(x, y=None, data=None, filter=None, by=None, facet=None,
1041
1969
  labels_size=0.90 if labels_size is None else labels_size,
1042
1970
  labels_color=labels_color,
1043
1971
  labels_decimals=labels_decimals,
1972
+ # applied only when given; R cuts by share, which a single
1973
+ # series of a statistic does not carry, nor outside labels
1974
+ labels_cut=(None if (labels_position == "out" or (
1975
+ isinstance(tbl, pd.Series) and y_ser is not None))
1976
+ else labels_cut),
1044
1977
  legend_title=legend_title, legend_position=legend_position,
1045
1978
  legend_labels=legend_labels, legend_horiz=legend_horiz,
1046
1979
  legend_size=legend_size, legend_abbrev=legend_abbrev,
@@ -1052,4 +1985,5 @@ def Chart(x, y=None, data=None, filter=None, by=None, facet=None,
1052
1985
 
1053
1986
 
1054
1987
  # font_size= scales all text of the returned figure
1055
- Chart = font_scaled(Chart)
1988
+ from .style import with_theme as _with_theme # noqa: E402
1989
+ Chart = font_scaled(attach_stats(_with_theme(Chart, own_param=True)))