lessPython 0.2.0__py3-none-any.whl → 0.3.1__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 (59) hide show
  1. lessPy/ANOVA.py +74 -15
  2. lessPy/Chart.py +431 -88
  3. lessPy/Correlation.py +40 -11
  4. lessPy/Flows.py +3 -3
  5. lessPy/Logit.py +15 -11
  6. lessPy/Regression.py +27 -21
  7. lessPy/X.py +280 -42
  8. lessPy/XY.py +1332 -239
  9. lessPy/__init__.py +12 -2
  10. lessPy/_themes.py +5007 -0
  11. lessPy/bc_items_plotly.py +33 -20
  12. lessPy/bc_plotly.py +27 -10
  13. lessPy/bubble_plotly.py +33 -17
  14. lessPy/corCFA.py +9 -5
  15. lessPy/corProp.py +8 -4
  16. lessPy/corReflect.py +8 -4
  17. lessPy/corReorder.py +8 -4
  18. lessPy/corScree.py +12 -8
  19. lessPy/data/StockPrice.csv +1401 -1386
  20. lessPy/details.py +17 -4
  21. lessPy/dn_plotly.py +74 -54
  22. lessPy/dot_plotly.py +9 -8
  23. lessPy/freq_poly_plotly.py +18 -11
  24. lessPy/getColors.py +247 -20
  25. lessPy/hier_plotly.py +41 -14
  26. lessPy/hs_plotly.py +23 -11
  27. lessPy/pie_plotly.py +1 -1
  28. lessPy/pivot.py +39 -9
  29. lessPy/plotly_utils.py +162 -29
  30. lessPy/plt_add.py +2 -7
  31. lessPy/plt_contour.py +6 -6
  32. lessPy/plt_contour_facet.py +3 -3
  33. lessPy/plt_mat_plotly.py +85 -12
  34. lessPy/plt_plotly.py +34 -18
  35. lessPy/plt_smooth.py +4 -4
  36. lessPy/plt_time.py +10 -3
  37. lessPy/prob_norm.py +7 -3
  38. lessPy/prob_tcut.py +7 -3
  39. lessPy/prob_znorm.py +7 -1
  40. lessPy/profile_plotly.py +5 -4
  41. lessPy/radar_plotly.py +4 -3
  42. lessPy/rcolors.py +461 -0
  43. lessPy/simCImean.py +7 -3
  44. lessPy/simCLT.py +7 -3
  45. lessPy/simFlips.py +7 -3
  46. lessPy/simMeans.py +7 -3
  47. lessPy/stats_out.py +257 -11
  48. lessPy/style.py +167 -0
  49. lessPy/sunflower_plotly.py +141 -0
  50. lessPy/ttest.py +17 -13
  51. lessPy/utils.py +146 -6
  52. lessPy/vbs_plotly.py +39 -27
  53. lessPy/x_console.py +763 -0
  54. {lesspython-0.2.0.dist-info → lesspython-0.3.1.dist-info}/METADATA +13 -2
  55. lesspython-0.3.1.dist-info/RECORD +89 -0
  56. {lesspython-0.2.0.dist-info → lesspython-0.3.1.dist-info}/WHEEL +1 -1
  57. lesspython-0.2.0.dist-info/RECORD +0 -84
  58. {lesspython-0.2.0.dist-info → lesspython-0.3.1.dist-info}/licenses/LICENSE +0 -0
  59. {lesspython-0.2.0.dist-info → lesspython-0.3.1.dist-info}/top_level.txt +0 -0
lessPy/XY.py CHANGED
@@ -63,20 +63,24 @@ from .plt_forecast import plt_forecast
63
63
  from .plt_plotly import plt_plotly
64
64
  from .plt_smooth import plt_smooth
65
65
  from .plt_time import plt_time
66
+ from .sunflower_plotly import sunflower_plotly
66
67
  from .plt_mat_plotly import scatter_matrix
68
+ from . import x_console as xc
69
+ from .stats_out import _stat_by_levels
67
70
  from .stats_out import (
68
71
  facet_summary, md_outliers, resolve_quiet, xy_stats)
69
72
  from .plotly_utils import (
70
73
  BASE_COLORS, as_plotly_color, axis_format, axis_num,
71
74
  facet_fig, facet_panels, finish_facet,
72
75
  get_tick_fmt, legend_style, make_trans, plot_border,
73
- plotly_style, square_layout, sym_at, to_hex, x_grid,
74
- font_scaled)
76
+ plotly_style, square_layout, marks, to_hex, x_grid,
77
+ font_scaled, by_colors)
75
78
  from .utils import (
76
- category_order, get_column, get_option, pretty, resolve_facet,
79
+ STAT_FUN, STAT_LBL, category_order, get_column, get_option,
80
+ pretty, resolve_facet,
77
81
  )
78
82
 
79
- _FORMS = ("scatter", "smooth", "contour")
83
+ _FORMS = ("scatter", "smooth", "contour", "sunflower")
80
84
  _FITS = ("off", "loess", "lm", "ls", "null", "exp", "quad",
81
85
  "power", "log")
82
86
 
@@ -191,55 +195,362 @@ def _fit_new_table(groups, fit, fit_power, x_new, x_name, y_name,
191
195
  return lines
192
196
 
193
197
 
194
- def _loess(xv, yv, span):
195
- """R-style loess at the sorted x values: local quadratic
196
- regression with tri-cube weights, gaussian family, computed
197
- exactly at each x (R surface="direct"). Also the SE of fit
198
- from the equivalent-kernel rows, as predict(l.ln, se=TRUE).
199
- R analog: .plt.fit() loess(y.lv ~ x.lv, span=span)"""
198
+ def _loess_rows(xs, q_pt, span, degree, rw):
199
+ """The local fit at q_pt as linear operators on y: rows giving
200
+ the fitted value and the slope there. Tri-cube weights on the
201
+ floor(n*span) nearest points (times the robustness weights),
202
+ polynomial of the given degree centered at q_pt.
203
+ R analog: ehg127() of the loess Fortran"""
204
+ n = len(xs)
205
+ q = min(n, max(int(span * n + 1e-5), degree + 1))
206
+ d = np.abs(xs - q_pt)
207
+ h = np.partition(d, q - 1)[q - 1]
208
+ if span > 1: # window widens past data,
209
+ h *= span ** 0.5 # verified against R loess
210
+ if h > 0:
211
+ w = np.clip(1 - (d / h) ** 3, 0, 1) ** 3
212
+ else: # window is exact x ties
213
+ w = (d == 0).astype(float)
214
+ w = w * rw
215
+ nz = np.flatnonzero(w)
216
+ # x in units of the window, so the powers of a large-valued x,
217
+ # such as dates as days, keep pinv well conditioned; the slope
218
+ # back in units of x
219
+ sc = h if h > 0 else 1.0
220
+ xc = (xs[nz] - q_pt) / sc
221
+ X = np.column_stack([xc ** k for k in range(degree + 1)])
222
+ XtW = X.T * w[nz]
223
+ B = np.linalg.pinv(XtW @ X) @ XtW
224
+ val = np.zeros(n)
225
+ slope = np.zeros(n)
226
+ val[nz] = B[0]
227
+ slope[nz] = B[1] / sc
228
+ return val, slope
229
+
230
+
231
+ def _loess_kd(xs, span, cell=0.2):
232
+ """Leaf cells of R's kd tree on one sorted x: the bounding box
233
+ widened by 0.5% each side, a cell split at its median x (moved
234
+ off ties) while it holds more than floor(n*span*cell) points.
235
+ Returns (lower, upper, split) per leaf, the split for the walk
236
+ down the tree. R analog: ehg126(), ehg124()"""
237
+ n = len(xs)
238
+ lo, hi = float(xs[0]), float(xs[-1])
239
+ mu = 0.005 * max(hi - lo, 1e-10 * max(abs(lo), abs(hi)) + 1e-30)
240
+ fc = int(np.floor(n * span * cell))
241
+ nodes = [] # (l, u, vlo, vhi), 1-based
242
+
243
+ def build(l, u, vlo, vhi):
244
+ leaf = (u - l) + 1 <= fc
245
+ if not leaf:
246
+ m = (l + u) // 2
247
+ off = 0
248
+ while l <= m + off < u:
249
+ if xs[m + off - 1] == xs[m + off]:
250
+ off = -off
251
+ if off >= 0:
252
+ off += 1
253
+ else:
254
+ m += off
255
+ break
256
+ v = float(xs[m - 1])
257
+ leaf = v == vlo or v == vhi
258
+ if leaf:
259
+ nodes.append((vlo, vhi))
260
+ return
261
+ build(l, m, vlo, v)
262
+ build(m + 1, u, v, vhi)
263
+
264
+ build(1, n, lo - mu, hi + mu)
265
+ return nodes
266
+
267
+
268
+ def _loess(xv, yv, span, degree=2, family="gaussian"):
269
+ """R's default loess at the sorted x values: local fits of the
270
+ given degree at the vertices of a kd tree, each giving a value
271
+ and a slope, joined by cubic Hermite interpolation within each
272
+ cell (surface="interpolate"). family="symmetric" reweights by
273
+ the bisquare of the residuals over 4 iterations. Also the SE of
274
+ fit from the rows of the resulting smoother, as predict(l.ln,
275
+ se=TRUE). span may be given as (span, degree, family).
276
+ R analog: .plt.fit() loess(y.lv ~ x.lv, span, degree, family)"""
277
+ if isinstance(span, tuple):
278
+ span, degree, family = span
200
279
  od = np.argsort(xv, kind="stable")
201
- xs, ys = xv[od], yv[od]
280
+ xs, ys = np.asarray(xv, float)[od], np.asarray(yv, float)[od]
202
281
  n = len(xs)
203
- q = min(n, max(int(span * n), 3)) # points in the window
204
- f = np.empty(n)
205
- l2 = np.empty(n) # ||l_i||^2, for se.fit
206
- trL = 0.0 # trace of the smoother L
207
- for i in range(n):
208
- d = np.abs(xs - xs[i])
209
- h = np.partition(d, q - 1)[q - 1]
210
- if span > 1: # window widens past data,
211
- h *= span ** 0.5 # verified against R loess
212
- if h > 0:
213
- w = np.clip(1 - (d / h) ** 3, 0, 1) ** 3
214
- else: # window is exact x ties
215
- w = (d == 0).astype(float)
216
- nz = np.flatnonzero(w)
217
- xc = xs[nz] - xs[i]
218
- X = np.column_stack((np.ones(len(nz)), xc, xc * xc))
219
- XtW = X.T * w[nz]
220
- li = (np.linalg.pinv(XtW @ X) @ XtW)[0] # equiv. kernel
221
- f[i] = li @ ys[nz]
222
- l2[i] = li @ li
223
- trL += li[np.searchsorted(nz, i)]
224
- res = ys - f
225
- dof = n - 2 * trL + l2.sum() # tr[(I-L)'(I-L)]
282
+ cells = _loess_kd(xs, span)
283
+ verts = sorted({v for c in cells for v in c})
284
+ rw = np.ones(n)
285
+ n_iter = 4 if family == "symmetric" else 1
286
+ for it in range(n_iter):
287
+ rows = {v: _loess_rows(xs, v, span, degree, rw) for v in verts}
288
+ L = np.empty((n, n))
289
+ for i, z in enumerate(xs):
290
+ for v0, v1 in cells: # x on a split goes left
291
+ if z <= v1:
292
+ break
293
+ hh = v1 - v0
294
+ u = (z - v0) / hh
295
+ g0, s0 = rows[v0]
296
+ g1, s1 = rows[v1]
297
+ L[i] = ((1 - u) ** 2 * (1 + 2 * u) * g0
298
+ + u * u * (3 - 2 * u) * g1
299
+ + (u * (1 - u) ** 2 * s0 - u * u * (1 - u) * s1) * hh)
300
+ f = L @ ys
301
+ res = ys - f
302
+ if it < n_iter - 1: # R lowesw(): bisquare weights
303
+ cmad = 6 * np.median(np.abs(res))
304
+ if cmad < np.finfo(float).tiny:
305
+ rw = np.ones(n)
306
+ else:
307
+ r = np.abs(res)
308
+ rw = np.where(r > 0.999 * cmad, 0.0,
309
+ np.where(r > 0.001 * cmad,
310
+ (1 - (r / cmad) ** 2) ** 2, 1.0))
311
+ l2 = (L ** 2).sum(axis=1)
312
+ dof = n - 2 * np.trace(L) + l2.sum() # tr[(I-L)'(I-L)]
313
+ # the t quantile of the band takes the fit's lookup degrees of
314
+ # freedom, delta1^2 / delta2. R analog: predict.loess()$df
315
+ IL = np.eye(n) - L
316
+ M = IL.T @ IL
317
+ _loess.df = dof ** 2 / (M * M.T).sum() # tr(M^2)
226
318
  return xs, ys, f, np.sqrt(res @ res / dof * l2)
227
319
 
228
320
 
321
+ _QQ_LAB = {".normal": "Normal Quantiles",
322
+ ".lognormal": "Lognormal Quantiles",
323
+ ".exponential": "Exponential Quantiles",
324
+ ".uniform": "Uniform Quantiles"}
325
+
326
+
327
+ def _qq_quantiles(y, key):
328
+ """Theoretical quantiles, one per value of y in the row order of
329
+ y (NaN where y is missing): the i-th smallest y pairs with the
330
+ i-th smallest quantile of the distribution fit by y's moments.
331
+ R analog: .qq.quantiles() (qq.R)"""
332
+ y = np.asarray(y, dtype=float)
333
+ ok = ~np.isnan(y)
334
+ n = int(ok.sum())
335
+ if n < 3:
336
+ raise ValueError(
337
+ "A q-q chart requires at least 3 non-missing values of y, "
338
+ f"but only {n} found.")
339
+ yv = y[ok]
340
+ m, sd = yv.mean(), yv.std(ddof=1)
341
+ if sd == 0:
342
+ raise ValueError(
343
+ "All values of y are the same, so there is no "
344
+ "distribution\nto compare against a theoretical "
345
+ "distribution.")
346
+ if key == ".lognormal" and (yv <= 0).any():
347
+ raise ValueError(
348
+ "A lognormal distribution is defined only for positive "
349
+ f"values,\nbut y has {(yv <= 0).sum()} value(s) of zero or "
350
+ "less.\n\nFor data that include zero, consider .exponential")
351
+ if key == ".exponential" and (yv < 0).any():
352
+ raise ValueError(
353
+ "An exponential distribution is defined only for "
354
+ f"non-negative\nvalues, but y has {(yv < 0).sum()} "
355
+ "negative value(s).")
356
+ a = 3 / 8 if n <= 10 else 0.5 # R ppoints()
357
+ p = (np.arange(1, n + 1) - a) / (n + 1 - 2 * a)
358
+ if key == ".normal":
359
+ q = sps.norm.ppf(p, m, sd)
360
+ elif key == ".lognormal":
361
+ lg = np.log(yv)
362
+ q = np.exp(sps.norm.ppf(p, lg.mean(), lg.std(ddof=1)))
363
+ elif key == ".exponential":
364
+ q = -m * np.log1p(-p)
365
+ else:
366
+ q = m - np.sqrt(3) * sd + p * 2 * np.sqrt(3) * sd
367
+ out = np.full(len(y), np.nan)
368
+ rk = np.empty(n, dtype=int) # rank, ties.method="first"
369
+ rk[np.argsort(yv, kind="stable")] = np.arange(n)
370
+ out[ok] = q[rk]
371
+ return out
372
+
373
+
374
+ def _qq_reference(fig, fit_color, fit_lwd):
375
+ """The 45-degree reference of a q-q chart in every panel, from
376
+ the range of that panel's plotted values, drawn beneath the
377
+ points. R analog: plt.plotly.R qq reference"""
378
+ panels = {}
379
+ for tr in fig.data:
380
+ if tr.type != "scatter" or tr.x is None or tr.y is None:
381
+ continue
382
+ key = (tr.xaxis or "x", tr.yaxis or "y")
383
+ v = np.r_[np.asarray(tr.x, float), np.asarray(tr.y, float)]
384
+ v = v[np.isfinite(v)]
385
+ if len(v):
386
+ lo, hi = panels.get(key, (np.inf, -np.inf))
387
+ panels[key] = (min(lo, v.min()), max(hi, v.max()))
388
+ refs = [go.Scatter(x=[lo, hi], y=[lo, hi], mode="lines",
389
+ xaxis=xa, yaxis=ya,
390
+ line=dict(color=as_plotly_color(get_option(
391
+ "fit_color", "#5C4032")
392
+ if fit_color is None else fit_color),
393
+ width=1.5 if fit_lwd is None else fit_lwd),
394
+ showlegend=False, hoverinfo="skip")
395
+ for (xa, ya), (lo, hi) in panels.items()]
396
+ k = len(refs)
397
+ fig.add_traces(refs)
398
+ fig.data = fig.data[-k:] + fig.data[:-k] if k else fig.data
399
+ return fig
400
+
401
+
402
+ def _ts_ylab(y_name, unit, aggregated, ts_agg):
403
+ """Default value-axis label of a time series: the aggregation
404
+ and the time unit, as "Total Sales by Year"; a vector of y
405
+ (y_name None) is named by its legend, so "Total by Year", and
406
+ with no aggregation to report either, no label. R analog: the
407
+ y.lab of .plt.main() for a date x"""
408
+ if unit in (None, "unknown"):
409
+ return y_name or ""
410
+ tu = "days" if unit == "days7" else unit
411
+ tu = tu[:-1] if tu.endswith("s") else tu
412
+ agg = (("Total" if ts_agg == "sum" else "Mean")
413
+ if aggregated else "")
414
+ if y_name is None and not agg:
415
+ return ""
416
+ return " ".join(p for p in (agg, y_name, "by", tu.capitalize())
417
+ if p)
418
+
419
+
229
420
  def _se_band(xs, ys, f, level):
230
421
  """SE band polygon about a least-squares line: fit +/- t * SE
231
422
  of the conditional mean. R analog: plt.main.R se bands, from
232
- predict(se=TRUE) and qt(prb, n-1)."""
423
+ predict(se=TRUE) and qt(prb, df), df the residual df. None
424
+ when the band is undefined, fewer than 3 points or a single x
425
+ value, where R's se is NaN and no band is drawn."""
233
426
  n = len(xs)
427
+ if n < 3 or np.ptp(xs) == 0:
428
+ return None
234
429
  mse = ((ys - f) ** 2).sum() / (n - 2)
235
430
  xbar = xs.mean()
236
431
  sxx = ((xs - xbar) ** 2).sum()
237
432
  se = np.sqrt(mse * (1 / n + (xs - xbar) ** 2 / sxx))
238
- tq = sps.t.ppf((1 + level) / 2, n - 1)
433
+ tq = sps.t.ppf((1 + level) / 2, n - 2) # the residual df
239
434
  return (np.concatenate([xs, xs[::-1]]),
240
435
  np.concatenate([f + tq * se, (f - tq * se)[::-1]]))
241
436
 
242
437
 
438
+ def _plt_bins(x_ser, y_ser, x, y, n_bins, stat, by, facet, pt_size,
439
+ ylab, digits_d, show):
440
+ """Bin x into n_bins equal intervals, R's cut() with the range
441
+ extended by 1/1000, and take the mean or median of y in each.
442
+ Returns the midpoints and statistics of the nonempty bins as the
443
+ new x and y, the y label, each point's pixel diameter, and the
444
+ bin counts, and prints R's two tables. R analog: .plt.bins()"""
445
+ st = "mean" if stat is None else stat
446
+ if st not in ("mean", "median"):
447
+ raise ValueError(
448
+ "stat can only take two values for binning: mean and "
449
+ "median")
450
+ if by is not None or facet is not None:
451
+ raise ValueError("n_bins bins one x variable: no by or facet")
452
+ if (not pd.api.types.is_numeric_dtype(x_ser)
453
+ or not pd.api.types.is_numeric_dtype(y_ser)):
454
+ raise ValueError("n_bins: both variables must be numeric")
455
+ n_bins = int(n_bins)
456
+ if n_bins < 2:
457
+ raise ValueError("Need at least two bins set by n_bins")
458
+ xv = x_ser.to_numpy(dtype=float)
459
+ yv = y_ser.to_numpy(dtype=float)
460
+ ok_x = np.isfinite(xv)
461
+ if len(np.unique(xv[ok_x])) < n_bins and show:
462
+ print(f"You specified {n_bins} bins, but only "
463
+ f"{len(np.unique(xv[ok_x]))} values of {x}\n"
464
+ "Empty bins will appear in the solution\n")
465
+ lo, hi = float(np.nanmin(xv)), float(np.nanmax(xv))
466
+ dx = hi - lo
467
+ bns = np.linspace(lo, hi, n_bins + 1)
468
+ bns[0], bns[-1] = lo - dx / 1000, hi + dx / 1000
469
+ mid = bns[:-1] + (bns[1:] - bns[:-1]) / 2
470
+ n = np.zeros(n_bins, dtype=int)
471
+ m = np.full(n_bins, np.nan)
472
+ for i in range(n_bins):
473
+ ys = yv[(xv > bns[i]) & (xv <= bns[i + 1])]
474
+ ys = ys[np.isfinite(ys)]
475
+ n[i] = len(ys)
476
+ if len(ys):
477
+ m[i] = ys.mean() if st == "mean" else np.median(ys)
478
+
479
+ if show:
480
+ def f_(v, d):
481
+ return f"{v:.{d}f}"
482
+ dx_ = 0 if np.all(xv[ok_x] == np.round(xv[ok_x])) else digits_d
483
+ ok_y = np.isfinite(yv)
484
+ dy_ = 0 if np.all(yv[ok_y] == np.round(yv[ok_y])) else digits_d
485
+ rows = [("n", str(ok_x.sum()), str(ok_y.sum())),
486
+ ("n.miss", str((~ok_x).sum()), str((~ok_y).sum())),
487
+ ("min", f_(np.nanmin(xv), dx_), f_(np.nanmin(yv), dy_)),
488
+ ("max", f_(np.nanmax(xv), dx_), f_(np.nanmax(yv), dy_)),
489
+ ("mean", f_(np.nanmean(xv), digits_d),
490
+ f_(np.nanmean(yv), digits_d))]
491
+ w1 = max(len(x), max(len(r[1]) for r in rows))
492
+ w2 = max(len(y), max(len(r[2]) for r in rows))
493
+ print("\nSummary Stats\n")
494
+ print(f"{'':7} {x:>{w1}} {y:>{w2}}")
495
+ for r in rows:
496
+ print(f"{r[0]:7} {r[1]:>{w1}} {r[2]:>{w2}}")
497
+ brk = [f"{b:.{digits_d}f}" for b in bns]
498
+ labs = [f"({brk[i]},{brk[i + 1]}]" for i in range(n_bins)]
499
+ labs[0] = "[" + labs[0][1:]
500
+ tbl = pd.DataFrame({
501
+ "bin": labs, "n": n,
502
+ "midpt": [f"{v:.{digits_d}f}" for v in mid],
503
+ st: ["NaN" if not np.isfinite(v) else f"{v:.{digits_d}f}"
504
+ for v in m]}, index=range(1, n_bins + 1))
505
+ print(f"\n{st} of {y} for levels of {x}\n")
506
+ print(tbl.to_string())
507
+
508
+ keep = n > 0
509
+ if isinstance(pt_size, (int, float)) and pt_size != 1:
510
+ cex = np.full(keep.sum(), float(pt_size))
511
+ else: # R: max size about 10
512
+ cex = (n[keep] / (n.max() / 10)) ** 0.6
513
+ x_new = pd.Series(mid[keep], name=x_ser.name)
514
+ y_new = pd.Series(m[keep], name=y_ser.name)
515
+ ylab = f"{st} of {y}" if ylab is None else ylab
516
+ return x_new, y_new, ylab, cex * 7.25, n[keep]
517
+
518
+
519
+ def _as_years(s):
520
+ """A run of consecutive integers from 10 to 9999 as the dates of
521
+ Jan 1 of those years, else None. R analog: XY.R ~733"""
522
+ if (not pd.api.types.is_numeric_dtype(s)
523
+ or pd.api.types.is_bool_dtype(s) or len(s) < 2):
524
+ return None
525
+ v = s.to_numpy(dtype=float)
526
+ if (np.isnan(v).any() or (v != np.round(v)).any()
527
+ or not (np.diff(v) == 1).all()
528
+ or v.min() < 10 or v.max() > 9999):
529
+ return None
530
+ out = pd.Series(pd.to_datetime([f"{int(t):04d}-01-01" for t in v]),
531
+ index=s.index, name=s.name)
532
+ return out
533
+
534
+
535
+ def _ellipse_levels(ellipse):
536
+ """The confidence levels of the data ellipses: True is 0.95, a
537
+ number one level, a list several, as R's ellipse=c(.3,.5,.9).
538
+ False, None, or 0 is none."""
539
+ if ellipse is True:
540
+ return [0.95]
541
+ if ellipse is None or ellipse is False:
542
+ return []
543
+ lvls = (list(ellipse) if isinstance(ellipse, (list, tuple,
544
+ np.ndarray))
545
+ else [ellipse])
546
+ lvls = [float(lv) for lv in lvls if lv]
547
+ for lv in lvls:
548
+ if not 0 < lv < 1:
549
+ raise ValueError("ellipse is the confidence level of "
550
+ "a data ellipse, between 0 and 1")
551
+ return lvls
552
+
553
+
243
554
  def _ellipse_region(xv, yv, level, npoints=100):
244
555
  """Bivariate-normal data ellipse, the Murdoch and Chow
245
556
  construction of the ellipse package that XY() cites.
@@ -260,7 +571,7 @@ def _xy_facet(xv, yv, by_arr, by_order, facet_arr, facet_order,
260
571
  ellipse, ellipse_fill, ellipse_color, ellipse_lwd,
261
572
  x_lab, y_lab, main, digits_d,
262
573
  facet2_arr=None, facet2_order=None,
263
- facet2_name=None, n_col=1):
574
+ facet2_name=None, n_col=1, fit_errors=False):
264
575
  """One scatter panel per facet level on shared axes,
265
576
  following the faceted-histogram conventions (_hs_facet):
266
577
  first level on the top panel, strip labels, legend
@@ -291,9 +602,10 @@ def _xy_facet(xv, yv, by_arr, by_order, facet_arr, facet_order,
291
602
  if fit == "loess":
292
603
  xs, ys_s, f, se_f = _loess(xg, yg, span)
293
604
  fit_lines.append({"name": gname, "x": xs,
294
- "y": f, "g": g})
605
+ "y": f, "g": g,
606
+ "err": (xs, ys_s, f)})
295
607
  for lv in se_levels:
296
- tq = sps.t.ppf((1 + lv) / 2, len(xs) - 1)
608
+ tq = sps.t.ppf((1 + lv) / 2, _loess.df)
297
609
  se_polys.append((
298
610
  np.concatenate([xs, xs[::-1]]),
299
611
  np.concatenate(
@@ -303,14 +615,17 @@ def _xy_facet(xv, yv, by_arr, by_order, facet_arr, facet_order,
303
615
  xs, ys_s, f = _plt_fit(xg, yg, fit, fit_power)
304
616
  okf = np.isfinite(f)
305
617
  fit_lines.append({"name": gname, "x": xs[okf],
306
- "y": f[okf], "g": g})
618
+ "y": f[okf], "g": g,
619
+ "err": (xs[okf], ys_s[okf],
620
+ f[okf])})
307
621
  if fit == "lm":
308
622
  for lv in se_levels:
309
- se_polys.append(
310
- _se_band(xs, ys_s, f, lv))
311
- if ellipse and len(xg) >= 3:
312
- ellipses.append((g, *_ellipse_region(xg, yg,
313
- ellipse)))
623
+ band = _se_band(xs, ys_s, f, lv)
624
+ if band is not None:
625
+ se_polys.append(band)
626
+ if len(xg) >= 3:
627
+ for lv in _ellipse_levels(ellipse):
628
+ ellipses.append((g, *_ellipse_region(xg, yg, lv)))
314
629
  panels.append((pts, fit_lines, se_polys, ellipses))
315
630
  for fl in fit_lines:
316
631
  xs_all.append(fl["x"]); ys_all.append(fl["y"])
@@ -335,22 +650,22 @@ def _xy_facet(xv, yv, by_arr, by_order, facet_arr, facet_order,
335
650
  row, col = pos[i]
336
651
  for g, (gname, xg, yg) in enumerate(pts):
337
652
  fig.add_trace(go.Scatter(
338
- x=xg, y=yg, mode="markers",
653
+ x=xg, y=yg,
339
654
  name=None if gname is None else str(gname),
340
655
  legendgroup=(None if gname is None
341
656
  else str(gname)),
342
- marker=dict(
343
- symbol=sym_at(shape, g), size=px,
344
- sizemode="diameter",
657
+ **marks(shape, g, "markers", dict(
658
+ size=px, sizemode="diameter",
345
659
  color=make_trans(fills[g], pt_opacity),
346
660
  opacity=1,
347
- line=dict(color=to_hex(fills[g]), width=1)),
661
+ line=dict(color=as_plotly_color(fills[g]),
662
+ width=1))),
348
663
  hoverinfo="x+y" + ("+name" if has_groups else ""),
349
664
  showlegend=has_groups and i == 0,
350
665
  ), row=row, col=col)
351
666
  for g, ex, ey in ellipses:
352
- edge = (to_hex(fills[g]) if has_groups
353
- else to_hex(ellipse_color))
667
+ edge = (as_plotly_color(fills[g]) if has_groups
668
+ else as_plotly_color(ellipse_color))
354
669
  fig.add_trace(go.Scatter(
355
670
  x=ex, y=ey, mode="lines",
356
671
  line=dict(color=edge, width=ellipse_lwd),
@@ -364,6 +679,18 @@ def _xy_facet(xv, yv, by_arr, by_order, facet_arr, facet_order,
364
679
  fillcolor=as_plotly_color(se_fill),
365
680
  hoverinfo="skip", showlegend=False,
366
681
  ), row=row, col=col)
682
+ # fit_errors: each point's residual segment to its fitted
683
+ # value, as on the single panel (R plt.main.R ~1403)
684
+ for fl in (fit_lines if fit_errors else []):
685
+ ex, ey = [], []
686
+ for xe, ye, fe in zip(*fl["err"]):
687
+ ex += [xe, xe, None]
688
+ ey += [ye, fe, None]
689
+ fig.add_trace(go.Scatter(
690
+ x=ex, y=ey, mode="lines",
691
+ line=dict(color="#822823", width=1),
692
+ hoverinfo="skip", showlegend=False,
693
+ ), row=row, col=col)
367
694
  for fl in fit_lines:
368
695
  if len(fl["x"]) < 2:
369
696
  continue
@@ -373,8 +700,8 @@ def _xy_facet(xv, yv, by_arr, by_order, facet_arr, facet_order,
373
700
  name="Fit" if single else f"Fit: {fl['name']}",
374
701
  legendgroup=("fit" if single
375
702
  else str(fl["name"])),
376
- line=dict(color=(to_hex(fit_color) if single
377
- else to_hex(fills[fl["g"]])),
703
+ line=dict(color=(as_plotly_color(fit_color) if single
704
+ else as_plotly_color(fills[fl["g"]])),
378
705
  width=fit_lwd),
379
706
  hoverinfo="skip",
380
707
  showlegend=i == 0,
@@ -432,7 +759,7 @@ def _series_overlay(V, names, shared, on, fills, shape, pt_size,
432
759
  xs, ys_s, f, se_f = _loess(xg, yg, span)
433
760
  fit_lines.append((xs, f))
434
761
  for lv in se_levels:
435
- tq = sps.t.ppf((1 + lv) / 2, len(xs) - 1)
762
+ tq = sps.t.ppf((1 + lv) / 2, _loess.df)
436
763
  se_polys.append((
437
764
  np.concatenate([xs, xs[::-1]]),
438
765
  np.concatenate([f + tq * se_f,
@@ -443,9 +770,12 @@ def _series_overlay(V, names, shared, on, fills, shape, pt_size,
443
770
  fit_lines.append((xs[okf], f[okf]))
444
771
  if fit == "lm":
445
772
  for lv in se_levels:
446
- se_polys.append(_se_band(xs, ys_s, f, lv))
447
- if ellipse and len(xg) >= 3:
448
- ellipses.append(_ellipse_region(xg, yg, ellipse))
773
+ band = _se_band(xs, ys_s, f, lv)
774
+ if band is not None:
775
+ se_polys.append(band)
776
+ if len(xg) >= 3:
777
+ for lv in _ellipse_levels(ellipse):
778
+ ellipses.append(_ellipse_region(xg, yg, lv))
449
779
  art.append((xg, yg, fit_lines, se_polys, ellipses))
450
780
  xs_all += [xg] + [fl[0] for fl in fit_lines] \
451
781
  + [ex for ex, _ in ellipses]
@@ -467,7 +797,7 @@ def _series_overlay(V, names, shared, on, fills, shape, pt_size,
467
797
  for ex, ey in ellipses:
468
798
  fig.add_trace(go.Scatter(
469
799
  x=ex, y=ey, mode="lines",
470
- line=dict(color=to_hex(fills[g]),
800
+ line=dict(color=as_plotly_color(fills[g]),
471
801
  width=ellipse_lwd),
472
802
  fill="toself",
473
803
  fillcolor=as_plotly_color(ellipse_fill),
@@ -478,14 +808,14 @@ def _series_overlay(V, names, shared, on, fills, shape, pt_size,
478
808
  fillcolor=as_plotly_color(se_fill),
479
809
  hoverinfo="skip", showlegend=False))
480
810
  fig.add_trace(go.Scatter(
481
- x=xg, y=yg, mode="markers", name=str(nm),
811
+ x=xg, y=yg, name=str(nm),
482
812
  legendgroup=str(nm),
483
- marker=dict(symbol=sym_at(shape, g), size=px,
484
- sizemode="diameter",
485
- color=make_trans(fills[g], pt_opacity),
486
- opacity=1,
487
- line=dict(color=to_hex(fills[g]),
488
- width=1)),
813
+ **marks(shape, g, "markers", dict(
814
+ size=px, sizemode="diameter",
815
+ color=make_trans(fills[g], pt_opacity),
816
+ opacity=1,
817
+ line=dict(color=as_plotly_color(fills[g]),
818
+ width=1))),
489
819
  hoverinfo="x+y+name", showlegend=True))
490
820
  for fx_l, fy_l in fit_lines:
491
821
  if len(fx_l) < 2:
@@ -493,7 +823,7 @@ def _series_overlay(V, names, shared, on, fills, shape, pt_size,
493
823
  fig.add_trace(go.Scatter(
494
824
  x=fx_l, y=fy_l, mode="lines",
495
825
  legendgroup=str(nm),
496
- line=dict(color=to_hex(fills[g]),
826
+ line=dict(color=as_plotly_color(fills[g]),
497
827
  width=fit_lwd),
498
828
  hoverinfo="skip", showlegend=False))
499
829
 
@@ -502,14 +832,14 @@ def _series_overlay(V, names, shared, on, fills, shape, pt_size,
502
832
  pad = 0.04 * (axT2[-1] - axT2[0])
503
833
  ax_y["range"] = [axT2[0] - pad, axT2[-1] + pad]
504
834
  ax_y.update(showgrid=True,
505
- gridcolor=to_hex(style_opts["grid_col"]),
835
+ gridcolor=as_plotly_color(style_opts["grid_col"]),
506
836
  gridwidth=1, griddash="dot")
507
837
  fig.update_layout(
508
838
  xaxis=ax_x, yaxis=ax_y,
509
839
  shapes=x_grid(axT1) + plot_border(),
510
840
  template=None,
511
- plot_bgcolor=to_hex(style_opts["panel_fill"]),
512
- paper_bgcolor=to_hex(style_opts["window_fill"]),
841
+ plot_bgcolor=as_plotly_color(style_opts["panel_fill"]),
842
+ paper_bgcolor=as_plotly_color(style_opts["window_fill"]),
513
843
  legend=legend_style("", style_opts),
514
844
  **square_layout(main=bool(main)))
515
845
  if main:
@@ -588,7 +918,7 @@ def _facet_series(V, names, shared, on, facet_arr, facet_order,
588
918
  color=make_trans(fills[g],
589
919
  pt_opacity),
590
920
  opacity=1,
591
- line=dict(color=to_hex(fills[g]),
921
+ line=dict(color=as_plotly_color(fills[g]),
592
922
  width=1)),
593
923
  hoverinfo="x+y+name",
594
924
  showlegend=i == 0,
@@ -599,7 +929,7 @@ def _facet_series(V, names, shared, on, facet_arr, facet_order,
599
929
  fig.add_trace(go.Scatter(
600
930
  x=fx_l, y=fy_l, mode="lines",
601
931
  legendgroup=str(names[g]),
602
- line=dict(color=to_hex(fills[g]),
932
+ line=dict(color=as_plotly_color(fills[g]),
603
933
  width=fit_lwd),
604
934
  hoverinfo="skip", showlegend=False,
605
935
  ), row=row, col=col)
@@ -678,13 +1008,13 @@ def _xy_series(x, y, data, x_multi, by, facet, form, n_row,
678
1008
  if ylab is None else ylab)
679
1009
 
680
1010
  k = len(names)
1011
+ fill = _expand_palette(fill, k)
681
1012
  if isinstance(fill, (list, tuple)):
682
1013
  fills = [fill[i % len(fill)] for i in range(k)]
683
1014
  elif fill is not None:
684
1015
  fills = [fill] * k
685
1016
  else:
686
- fills = [BASE_COLORS[i % len(BASE_COLORS)]
687
- for i in range(k)]
1017
+ fills = by_colors(k) # theme's palette, as R
688
1018
  if transparency is None:
689
1019
  transparency = get_option("trans_pt_fill", 0.10)
690
1020
  if ellipse is True:
@@ -736,13 +1066,19 @@ def _ts_facet(xv, yv, facet_arr, facet_order, fill0, border0,
736
1066
  pt_size, pt_opacity, x_lab, y_lab, main, digits_d,
737
1067
  area_fill=None, area_split=0,
738
1068
  facet_name=None, facet2_arr=None,
739
- facet2_order=None, facet2_name=None, n_col=1):
1069
+ facet2_order=None, facet2_name=None, n_col=1,
1070
+ by_arr=None, by_order=None, by_name=None,
1071
+ line_width=1.5, frcsts=None, ts_PI=0.95):
740
1072
  """One time-series panel per facet level on shared axes,
741
1073
  following the faceted-scatter conventions (_xy_facet):
742
1074
  first level on the top panel, strip labels. The x axis
743
1075
  uses plotly's native date ticks, as the single-panel time
744
1076
  series does. R analog: the lattice cont_cont path for a
745
- date x. facet2: the two-facet grid, rows = facet2 levels."""
1077
+ date x. facet2: the two-facet grid, rows = facet2 levels.
1078
+ by: several series per panel in the legend's colors, unfilled
1079
+ unless area_fill is asked for. frcsts: a forecast per panel
1080
+ (panel index -> plt_forecast result), every panel on one scale
1081
+ that holds the prediction intervals."""
746
1082
  labels, pos, sel, n_row_g, n_col = facet_panels(
747
1083
  facet_arr, facet_order, facet2_arr, facet2_order,
748
1084
  facet_name, facet2_name, n_col)
@@ -752,15 +1088,22 @@ def _ts_facet(xv, yv, facet_arr, facet_order, fill0, border0,
752
1088
  px = 5
753
1089
  mode_pts = "lines+markers" if px > 0 else "lines"
754
1090
 
755
- axT2 = pretty(float(np.nanmin(yv)), float(np.nanmax(yv)))
1091
+ y_all = [np.asarray(yv, float)]
1092
+ for fr in (frcsts or {}).values():
1093
+ y_all += [np.asarray(fr["y_lwr"], float),
1094
+ np.asarray(fr["y_upr"], float)]
1095
+ y_all = np.concatenate(y_all)
1096
+ axT2 = pretty(float(np.nanmin(y_all)), float(np.nanmax(y_all)))
756
1097
  fmt2 = get_tick_fmt(axT2, digits_d)
757
1098
  ax = {"axT1": None, "axL1": None,
758
1099
  "axT2": axT2,
759
1100
  "axL2": axis_format(axT2, digits_d)}
760
1101
 
761
1102
  ypart = f"%{{y:{fmt2}}}" if fmt2 else "%{y}"
762
- hover = (f"Date: %{{x|%Y-%m-%d}}<br>{y_lab.strip()}: "
763
- f"{ypart}<extra></extra>")
1103
+ xpart = ("Date: %{x|%Y-%m-%d}"
1104
+ if np.issubdtype(np.asarray(xv).dtype, np.datetime64)
1105
+ else f"{x_lab}: %{{x}}")
1106
+ hover = f"{xpart}<br>{y_lab.strip()}: {ypart}<extra></extra>"
764
1107
 
765
1108
  style_opts = plotly_style()
766
1109
  fig = facet_fig(n_row_g, n_col)
@@ -768,39 +1111,66 @@ def _ts_facet(xv, yv, facet_arr, facet_order, fill0, border0,
768
1111
  rng_pad = 0.04 * (axT2[-1] - axT2[0]) # panel-clipped
769
1112
  base = min(max(float(area_split), axT2[0] - rng_pad),
770
1113
  axT2[-1] + rng_pad)
1114
+ if by_arr is None:
1115
+ series = [(None, np.ones(len(xv), dtype=bool), fill0,
1116
+ border0)]
1117
+ else:
1118
+ by_s = np.asarray(by_arr).astype(str)
1119
+ cols = by_colors(len(by_order))
1120
+ series = [(str(lv), by_s == str(lv), cols[g], cols[g])
1121
+ for g, lv in enumerate(by_order)]
771
1122
  for i in range(n_f):
772
- m = sel[i]
773
1123
  row, col = pos[i]
774
- if area_fill is not None and m.any():
1124
+ for nm, bm, f_c, b_c in series:
1125
+ m = sel[i] & bm
1126
+ if not m.any():
1127
+ continue
1128
+ od = np.argsort(xv[m], kind="stable")
1129
+ xm, ym = xv[m][od], yv[m][od]
1130
+ if area_fill is not None:
1131
+ fig.add_trace(go.Scatter(
1132
+ x=np.concatenate([xm, xm[-1:], xm[:1]]),
1133
+ y=np.concatenate([ym, [base, base]]),
1134
+ mode="none", fill="toself",
1135
+ fillcolor=area_fill, hoverinfo="skip",
1136
+ showlegend=False,
1137
+ ), row=row, col=col)
1138
+ hv = hover if nm is None else (
1139
+ f"{by_name}: {nm}<br>" + hover)
775
1140
  fig.add_trace(go.Scatter(
776
- x=np.concatenate([xv[m], xv[m][-1:],
777
- xv[m][:1]]),
778
- y=np.concatenate([yv[m], [base, base]]),
779
- mode="none", fill="toself",
780
- fillcolor=area_fill, hoverinfo="skip",
781
- showlegend=False,
782
- ), row=row, col=col)
783
- fig.add_trace(go.Scatter(
784
- x=xv[m], y=yv[m], mode=mode_pts,
785
- marker=(dict(symbol="circle", size=px,
786
- sizemode="diameter",
787
- color=make_trans(fill0, pt_opacity),
788
- opacity=1,
789
- line=dict(color=to_hex(border0),
790
- width=1))
791
- if "markers" in mode_pts else None),
792
- line=dict(color=to_hex(border0), width=1.5),
793
- hovertemplate=hover, showlegend=False,
794
- ), row=row, col=col) # first level top-left
1141
+ x=xm, y=ym, mode=mode_pts if line_width > 0
1142
+ else "markers",
1143
+ marker=(dict(symbol="circle", size=px,
1144
+ sizemode="diameter",
1145
+ color=make_trans(f_c, pt_opacity),
1146
+ opacity=1,
1147
+ line=dict(color=as_plotly_color(b_c), width=1))
1148
+ if "markers" in mode_pts or line_width <= 0
1149
+ else None),
1150
+ line=dict(color=as_plotly_color(
1151
+ style_opts["segment_color"]
1152
+ if nm is None and get_option("segment_color_set")
1153
+ else b_c), width=line_width),
1154
+ name=nm, legendgroup=nm,
1155
+ hovertemplate=hv,
1156
+ showlegend=nm is not None and i == 0,
1157
+ ), row=row, col=col) # first level top-left
1158
+ if frcsts and i in frcsts:
1159
+ _forecast_traces(fig, frcsts[i], ts_PI, row=row, col=col,
1160
+ legend=i == min(frcsts))
795
1161
 
796
1162
  finish_facet(fig, labels, ax, x_lab, y_lab,
797
1163
  gridT1=None, style_opts=style_opts,
798
1164
  n_col=n_col, pos=pos)
1165
+ if by_arr is not None or frcsts:
1166
+ fig.update_layout(showlegend=True,
1167
+ legend=legend_style(by_name, style_opts)
1168
+ if by_arr is not None else None)
799
1169
  # ts panels: y spans the data; native date grid on x
800
1170
  pad = 0.04 * (axT2[-1] - axT2[0])
801
1171
  fig.update_yaxes(range=[axT2[0] - pad, axT2[-1] + pad])
802
1172
  fig.update_xaxes(showgrid=True,
803
- gridcolor=to_hex(style_opts["grid_col"]),
1173
+ gridcolor=as_plotly_color(style_opts["grid_col"]),
804
1174
  gridwidth=1)
805
1175
  if main:
806
1176
  title_size = round(16 * get_option("main_size", 1))
@@ -810,6 +1180,16 @@ def _ts_facet(xv, yv, facet_arr, facet_order, fill0, border0,
810
1180
  return fig
811
1181
 
812
1182
 
1183
+ def _expand_palette(col, n):
1184
+ """A palette name, such as fill="blues", as n of its colors,
1185
+ one per series or by group, as R's fill="blues". Any other
1186
+ value is returned unchanged."""
1187
+ from .Chart import _color_range, _palette_names
1188
+ if isinstance(col, str) and col in _palette_names():
1189
+ return _color_range(col, n)
1190
+ return col
1191
+
1192
+
813
1193
  def _area_color(col, opacity):
814
1194
  """Area fill color: "on" is the violin_fill option; an
815
1195
  explicit #RRGGBBAA keeps its own alpha; otherwise the point
@@ -880,11 +1260,14 @@ def _run_analysis(yv, digits_d, show_detail):
880
1260
  lines.append(f"\nTotal number of runs: {n_runs}")
881
1261
  lines.append("Total number of values that do not equal the "
882
1262
  f"median: {n - len(eq)}")
883
- if eq:
1263
+ if eq: # listed only with show_runs
884
1264
  if show_detail:
885
1265
  lines.append("\nValues ignored that equal the median")
886
1266
  lines += [f" #{j} {y[j - 1]:.{dd}f}" for j in eq]
887
- lines.append(f"Total number of values ignored: {len(eq)}")
1267
+ lines.append(f"Total number of values ignored: {len(eq)}")
1268
+ else:
1269
+ lines.append("Total number of values ignored that equal the "
1270
+ "median: 0")
888
1271
  return m, lines
889
1272
 
890
1273
 
@@ -903,18 +1286,19 @@ def _outlier_traces(fig, xv, yv, out_idx, labels, fill0,
903
1286
  sizemode="diameter",
904
1287
  color=make_trans(fill0, pt_opacity),
905
1288
  opacity=1,
906
- line=dict(color=to_hex(fill0), width=1.5)),
1289
+ line=dict(color=as_plotly_color(fill0), width=1.5)),
907
1290
  hoverinfo="x+y", showlegend=False))
908
1291
  font_sz = max(9, round(float(ID_size) * 14))
909
1292
  for i in out_idx:
910
1293
  fig.add_annotation(
911
1294
  x=xv[i], y=yv[i], text=str(labels[i]),
912
1295
  showarrow=False, yshift=-12,
913
- font=dict(color=to_hex(ID_color), size=font_sz,
1296
+ font=dict(color=as_plotly_color(ID_color), size=font_sz,
914
1297
  family="Arial"))
915
1298
 
916
1299
 
917
- def _forecast_traces(fig, frcst, ts_PI):
1300
+ def _forecast_traces(fig, frcst, ts_PI, row=None, col=None,
1301
+ legend=True):
918
1302
  """Overlay the forecast on the time series display: model-fit
919
1303
  line, PI band with dotted boundaries, forecast line + points.
920
1304
  R analog: plt.plotly.R forecast section (~lines 343-451),
@@ -925,6 +1309,16 @@ def _forecast_traces(fig, frcst, ts_PI):
925
1309
  xf = np.asarray(frcst["x_fit"]) # numpy datetimes: plain
926
1310
  xh = np.asarray(frcst["x_hat"]) # Timestamps break kaleido
927
1311
  yf, yh = frcst["y_fit"], frcst["y_hat"]
1312
+ at = {} if row is None else dict(row=row, col=col)
1313
+ _add = fig.add_trace
1314
+
1315
+ def add(tr, **kw): # one legend entry per role
1316
+ if not legend:
1317
+ tr.showlegend = False
1318
+ tr.legendgroup = tr.name
1319
+ _add(tr, **at)
1320
+
1321
+ fig = _Adder(fig, add)
928
1322
  fig.add_trace(go.Scatter(
929
1323
  x=xf, y=yf, mode="lines",
930
1324
  line={"color": fit_rgba, "width": 1.5},
@@ -953,6 +1347,12 @@ def _forecast_traces(fig, frcst, ts_PI):
953
1347
  name="Forecast", showlegend=True))
954
1348
 
955
1349
 
1350
+ class _Adder:
1351
+ """fig.add_trace routed through a placement function."""
1352
+ def __init__(self, fig, add):
1353
+ self._fig, self.add_trace = fig, add
1354
+
1355
+
956
1356
  def _ts_fitted_lines(frcst, yv, x_name, y_name, ts_unit, digits_d):
957
1357
  """Table of the observed y beside the model-fitted y over the
958
1358
  historical dates, printed when ts_fitted=True. R analog: the
@@ -980,22 +1380,24 @@ def _ts_fitted_lines(frcst, yv, x_name, y_name, ts_unit, digits_d):
980
1380
  def XY(x, y=None, data=None, filter=None, by=None, facet=None,
981
1381
  n_row=None, n_col=None,
982
1382
  form="scatter", stat=None, sort="0", show_runs=False,
983
- center_line="off",
1383
+ center_line=None,
984
1384
  contour_n=None, contour_nbins=None, contour_points=None,
985
1385
  contour_legend=None,
986
1386
  smooth_points=100, smooth_size=1, smooth_power=0.25,
987
- smooth_bins=128,
1387
+ smooth_bins=128, n_bins=None,
988
1388
  enhance=False,
989
1389
  segments_x=None, segments_y=None,
990
1390
  add=None, x1=None, y1=None, x2=None, y2=None,
991
1391
  fill=None, color=None, transparency=None,
992
1392
  pt_size=1, pt_shape="circle", line_width=1.5,
1393
+ radius=None, power=None,
993
1394
  jitter_x=None, jitter_y=None,
994
1395
  MD_cut=0, out_cut=0, out_shape="circle-open", out_size=1,
995
1396
  ID=None, ID_color="gray50", ID_size=0.6,
996
1397
  fit="off", fit_power=1, fit_se=None, fit_errors=False,
997
1398
  fit_new=None,
998
- fit_color=None, fit_lwd=None, span=0.75,
1399
+ fit_color=None, fit_lwd=None, fit_span_loess=0.75,
1400
+ fit_degree_loess=2, fit_family_loess="gaussian",
999
1401
  ellipse=0, ellipse_fill=None, ellipse_color=None,
1000
1402
  ellipse_lwd=None,
1001
1403
  ts_unit=None, ts_agg="sum", ts_stack=False,
@@ -1010,7 +1412,7 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1010
1412
  rotate_x=0, rotate_y=0, scale_x=None, scale_y=None,
1011
1413
  xlab=None, ylab=None, main=None, digits_d=None,
1012
1414
  quiet=None,
1013
- plot_errors=None):
1415
+ plot_errors=None, span=None):
1014
1416
  """Analytic view of the relationship between two numerical
1015
1417
  variables, optionally grouped (by=). A date x displays as a
1016
1418
  time series. Variables are strings naming columns of the
@@ -1034,14 +1436,54 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1034
1436
  (contour_n, contour_nbins, contour_points,
1035
1437
  contour_legend)):
1036
1438
  form = "contour"
1439
+ contour_n_user = contour_n # a matrix cell defaults to 8
1037
1440
  contour_n = 20 if contour_n is None else contour_n
1038
1441
  contour_nbins = 50 if contour_nbins is None else contour_nbins
1039
1442
  contour_points = bool(contour_points)
1040
1443
  contour_legend = bool(contour_legend)
1444
+ # fill="on" is the violin_fill color, as R (XY.R ~332)
1445
+ if isinstance(fill, str) and fill == "on" or (
1446
+ isinstance(fill, (list, tuple)) and "on" in fill):
1447
+ fill = get_option("violin_fill", "#7485975A")
1448
+ if pt_shape == "sunflower": # its earlier route, kept
1449
+ form, pt_shape = "sunflower", "circle"
1450
+ # the form is consulted before by is resolved, so a vector of by
1451
+ # gets the sunflower's own reason (XY.R)
1452
+ if form == "sunflower" and by is not None:
1453
+ raise ValueError(
1454
+ 'Parameter by is not available for form="sunflower".\n\n'
1455
+ "A petal count is defined for one set of coordinates, not\n"
1456
+ "separately within each group, so overlaid groups would\n"
1457
+ "interleave their petals at a shared coordinate.\n"
1458
+ "To stratify the same two variables, use facet, which "
1459
+ "draws\neach group in its own panel with its own "
1460
+ "coordinates.")
1461
+ if form == "sunflower" and (isinstance(x, (list, tuple))
1462
+ or isinstance(y, (list, tuple))):
1463
+ raise ValueError(
1464
+ "The petal count is defined for one set of coordinates, "
1465
+ "not\nseparately for each series, so a vector of x or y "
1466
+ 'variables\nis not available for form="sunflower". To '
1467
+ 'overlay the series,\nuse form="scatter".')
1468
+ if isinstance(by, (list, tuple)):
1469
+ raise ValueError(
1470
+ "Only one by variable is permitted, but more than one "
1471
+ f"specified:\n by = {list(by)}\n\n"
1472
+ "The groups of by are overlaid within the panel, "
1473
+ "distinguished by\ncolor and optionally symbol. That one "
1474
+ "channel is carried by the\nfirst variable, so a second "
1475
+ "has no encoding left by which to\nseparate its levels.\n"
1476
+ "To stratify by a second variable, use facet, which draws "
1477
+ "each of\nits groups in a panel of its own.")
1041
1478
  if form != "scatter" and by is not None:
1042
1479
  raise ValueError(
1043
- 'by= is active only for a scatter plot, '
1044
- 'form="scatter"')
1480
+ f'Parameter by is not available for form="{form}".\n\n'
1481
+ f"A {form} plot summarizes the joint density over the "
1482
+ "whole\nplotting region, so summaries superimposed on it "
1483
+ "would obstruct\none another rather than remain "
1484
+ "distinguishable.\nTo stratify the same two variables, "
1485
+ "use facet, which draws\neach group in its own panel with "
1486
+ "its own density.")
1045
1487
  if add is not None and form in ("contour", "smooth"):
1046
1488
  raise ValueError(
1047
1489
  'add is not active for "contour" or "smooth" plots')
@@ -1051,16 +1493,30 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1051
1493
  "no facet=")
1052
1494
  if axis_fmt not in ("K", ",", ".", ""):
1053
1495
  raise ValueError('axis_fmt: "K", ",", ".", or ""')
1054
- if center_line not in ("off", "mean", "median", "zero"):
1496
+ if center_line not in (None, "off", "mean", "median", "zero"):
1055
1497
  raise ValueError(
1056
1498
  'center_line: "off", "mean", "median", or "zero"')
1499
+ if span is not None:
1500
+ raise ValueError(
1501
+ "Parameter span has been renamed to fit_span_loess.\n\n"
1502
+ "It sets the span of a loess fit, so the name now says "
1503
+ "which\nfit it belongs to, alongside fit_degree_loess "
1504
+ "and fit_family_loess.")
1505
+ if fit_degree_loess not in (1, 2):
1506
+ raise ValueError("fit_degree_loess: 1 or 2")
1507
+ if fit_family_loess not in ("gaussian", "symmetric"):
1508
+ raise ValueError('fit_family_loess: "gaussian" or "symmetric"')
1509
+ # the loess settings travel together to every fit (R: loess(...,
1510
+ # span, degree, family) in .plt.fit())
1511
+ span = (float(fit_span_loess), int(fit_degree_loess),
1512
+ fit_family_loess)
1057
1513
  if plot_errors is not None:
1058
1514
  raise ValueError(
1059
1515
  "Parameter plot_errors has been renamed to fit_errors. "
1060
1516
  "The segments it draws join each point to the fitted line, "
1061
1517
  "so they are the errors of the fit, and the name now says "
1062
1518
  "which fit they belong to, as fit, fit_color, and fit_se do.")
1063
- if center_line != "off" and facet is not None:
1519
+ if center_line not in (None, "off") and facet is not None:
1064
1520
  raise ValueError(
1065
1521
  "center_line draws on a single panel: no facet=")
1066
1522
  if fit_errors:
@@ -1070,9 +1526,6 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1070
1526
  "line: specify fit=")
1071
1527
  if form != "scatter":
1072
1528
  raise ValueError('fit_errors applies to form="scatter"')
1073
- if facet is not None:
1074
- raise ValueError(
1075
- "fit_errors draws on a single panel: no facet=")
1076
1529
  if (scale_x is not None or scale_y is not None) \
1077
1530
  and facet is not None:
1078
1531
  raise ValueError(
@@ -1091,16 +1544,13 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1091
1544
  raise ValueError(
1092
1545
  "outlier flagging (MD_cut/out_cut) applies to "
1093
1546
  'the scatter form, form="scatter"')
1094
- if ts_unit is not None and facet is not None:
1095
- raise ValueError(
1096
- "ts_unit does not yet apply to facet plots")
1097
1547
  if (n_row is not None or n_col is not None) \
1098
1548
  and facet is None:
1099
1549
  raise ValueError("n_row and n_col lay out facet panels: "
1100
1550
  "specify facet=")
1101
1551
  if ts_agg not in ("sum", "mean"):
1102
1552
  raise ValueError('ts_agg: "sum" or "mean"')
1103
- if ts_stack and by is None:
1553
+ if ts_stack and by is None and not isinstance(y, (list, tuple)):
1104
1554
  raise ValueError(
1105
1555
  "ts_stack stacks multiple time series: specify by=")
1106
1556
  if (by is not None and ts_area_fill is not None
@@ -1112,14 +1562,35 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1112
1562
  if ts_ahead > 0:
1113
1563
  if ts_source not in ("fable", "classic"):
1114
1564
  raise ValueError('ts_source: "fable" or "classic"')
1115
- if by is not None:
1116
- raise ValueError(
1117
- "Can only forecast a single time series, "
1118
- "so no by=")
1119
- if facet is not None:
1120
- raise ValueError(
1121
- "Can only forecast a single time series, "
1122
- "so no facet=")
1565
+ if by is not None: # XY.R: the overlay channel
1566
+ raise ValueError( # is not available
1567
+ "A forecast draws its fit, prediction interval, and "
1568
+ "forecast\nin the series' own colors, so the overlay "
1569
+ "of by= groups is not\navailable to it. To forecast "
1570
+ "each group, use facet=, which\nforecasts each panel "
1571
+ "on one scale.")
1572
+
1573
+ # for the report: the caller's own names, the parameters named in
1574
+ # the call (which the suggestions skip), the digits as given
1575
+ data_in = data
1576
+ digits_user = digits_d
1577
+ x_call = x # as written, keyword included
1578
+ given = {nm for nm, v in (
1579
+ ("enhance", enhance or None), ("fill", fill), ("color", color),
1580
+ ("fit", None if fit == "off" else fit),
1581
+ ("out_cut", out_cut or None), ("MD_cut", MD_cut or None),
1582
+ ("pt_shape", None if pt_shape == "circle" else pt_shape),
1583
+ ("pt_size", None if pt_size == 1 else pt_size),
1584
+ ("line_width", None if line_width == 1.5 else line_width),
1585
+ ("ts_ahead", ts_ahead or None), ("ts_unit", ts_unit),
1586
+ ("ts_agg", None if ts_agg == "sum" else ts_agg),
1587
+ ("ts_seasons", ts_seasons), ("ts_area_fill", ts_area_fill),
1588
+ ("ts_stack", ts_stack or None)) if v is not None}
1589
+ show = not resolve_quiet(quiet)
1590
+
1591
+ def say(*comps):
1592
+ if show:
1593
+ print(xc.report(*comps))
1123
1594
 
1124
1595
  if filter is not None:
1125
1596
  data = data.query(filter)
@@ -1150,16 +1621,131 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1150
1621
  return scatter_matrix(
1151
1622
  mat_df, fit=("off" if fit in ("off", "null")
1152
1623
  else fit),
1153
- digits_d=digits_d, main=main)
1624
+ digits_d=digits_d, main=main, form=form,
1625
+ contour_n=contour_n_user,
1626
+ contour_nbins=contour_nbins,
1627
+ contour_points=contour_points, smooth_bins=smooth_bins,
1628
+ smooth_power=smooth_power)
1629
+ # a vector of y on a date x: one series per variable, overlaid
1630
+ # and keyed by name in the legend, drawn as a by= series would
1631
+ # be; the value axis reports only the aggregation and time unit
1632
+ # (XY.R, Aug 2026)
1633
+ # a vector of y over .Index: one run chart per variable, overlaid,
1634
+ # each numbered by row, as the date x above (XY.R run, n.ycol > 1)
1635
+ if (y_multi and not x_multi and isinstance(x, str)
1636
+ and x.lower() == ".index" and x not in data.columns):
1637
+ if by is not None or facet is not None:
1638
+ raise ValueError(
1639
+ "A vector of y variables over .Index overlays its "
1640
+ "variables as the\nseries of the run chart, so by and "
1641
+ "facet do not apply.")
1642
+ long = data[list(y)].copy()
1643
+ long[".Index"] = np.arange(1, len(data) + 1)
1644
+ long = long.melt(id_vars=[".Index"], value_vars=list(y),
1645
+ var_name="Series", value_name="Value")
1646
+ long["Series"] = pd.Categorical(long["Series"],
1647
+ categories=list(y))
1648
+ fig_l = XY(".Index", "Value", data=long, by="Series",
1649
+ center_line=("off" if center_line is None
1650
+ else center_line),
1651
+ pt_size=pt_size, line_width=line_width, fill=fill,
1652
+ xlab=xlab, ylab=("" if ylab is None else ylab),
1653
+ main=main, digits_d=digits_d, quiet=True,
1654
+ rotate_x=rotate_x, rotate_y=rotate_y)
1655
+ fig_l.update_layout(legend_title_text="")
1656
+ return fig_l
1657
+
1658
+ if y_multi and not x_multi and isinstance(x, str) \
1659
+ and x in data.columns:
1660
+ xd = data[x]
1661
+ if not pd.api.types.is_datetime64_any_dtype(xd) \
1662
+ and not pd.api.types.is_numeric_dtype(xd):
1663
+ from .date_infer import date_infer as _infer_dates
1664
+ try:
1665
+ xd = _infer_dates(xd)
1666
+ except Exception:
1667
+ pass
1668
+ if pd.api.types.is_datetime64_any_dtype(xd):
1669
+ if by is not None:
1670
+ raise ValueError(
1671
+ "A vector of y variables overlays its variables as "
1672
+ "the series of the\nplot, which spends the color "
1673
+ "dimension that by requires.\nTo display a "
1674
+ "grouping variable, use facet=, which panels the "
1675
+ "series.")
1676
+ fcols = ([facet] if isinstance(facet, str) else
1677
+ list(facet) if isinstance(facet, (list, tuple))
1678
+ else [])
1679
+ long = data[[x] + fcols + list(y)].copy()
1680
+ long[x] = xd
1681
+ long = long.melt(id_vars=[x] + fcols, value_vars=list(y),
1682
+ var_name="Series", value_name="Value")
1683
+ long["Series"] = pd.Categorical(long["Series"],
1684
+ categories=list(y))
1685
+ if ylab is None:
1686
+ from .plt_time import _infer_unit
1687
+ ylab = _ts_ylab(None, ts_unit if ts_unit is not None
1688
+ else _infer_unit(long[x].to_numpy()),
1689
+ ts_unit is not None, ts_agg)
1690
+ fig_l = XY(x, "Value", data=long, by="Series", facet=facet,
1691
+ ts_unit=ts_unit, ts_agg=ts_agg,
1692
+ ts_stack=ts_stack, ts_area_fill=ts_area_fill,
1693
+ ts_area_split=ts_area_split, pt_size=pt_size,
1694
+ line_width=line_width, n_row=n_row, n_col=n_col,
1695
+ fill=fill, xlab=xlab, ylab=ylab, main=main,
1696
+ digits_d=digits_d, quiet=True,
1697
+ rotate_x=rotate_x, rotate_y=rotate_y)
1698
+ fig_l.update_layout(legend_title_text="")
1699
+ pre_l, dn_l = xc.call_names(data_in)
1700
+ say(xc.suggest_series(
1701
+ pre_l, x, list(y), dn_l, given,
1702
+ "".join(f', {k}="{v}"' if isinstance(v, str)
1703
+ else f", {k}={v}"
1704
+ for k, v in (("ts_unit", ts_unit),)
1705
+ if v is not None), pt_size=pt_size))
1706
+ return fig_l
1707
+
1154
1708
  if x_multi or y_multi:
1155
- return _apply_rotate(_xy_series(
1709
+ # a vector against a categorical variable is Chart()'s dot
1710
+ # chart, named rather than drawn (XY.R)
1711
+ one = y if x_multi else x
1712
+ if (isinstance(one, str) and one in data.columns
1713
+ and not pd.api.types.is_numeric_dtype(data[one])
1714
+ and not pd.api.types.is_datetime64_any_dtype(
1715
+ data[one])):
1716
+ vec = x if x_multi else y
1717
+ vec_s = "[" + ", ".join(f"'{v}'" for v in vec) + "]"
1718
+ raise ValueError(
1719
+ "XY() requires a continuous x and a continuous y.\n"
1720
+ f"{one} is categorical.\n\nFor several values at each "
1721
+ f"level of {one}, use Chart()\nwith a vector of y "
1722
+ f"variables:\n Chart('{one}', y={vec_s}, form='dot')")
1723
+ fig_s = _apply_rotate(_xy_series(
1156
1724
  x, y, data, x_multi, by, facet, form, n_row, n_col,
1157
1725
  fill, transparency, pt_shape, pt_size,
1158
1726
  fit, fit_power, fit_se, fit_lwd, span,
1159
1727
  ellipse, ellipse_fill, ellipse_lwd,
1160
1728
  MD_cut, out_cut,
1161
- xlab, ylab, main, digits_d, quiet),
1729
+ xlab, ylab, main, digits_d, True),
1162
1730
  rotate_x, rotate_y)
1731
+ # the report: one correlation per plotted variable, each
1732
+ # headed by its name (R overwrote all but the last)
1733
+ pre, dname = xc.call_names(data_in)
1734
+ names = list(x) if x_multi else list(y)
1735
+ other = data[y if x_multi else x]
1736
+ comps = [xc.suggest_scatter(pre, x, y, dname, given)]
1737
+ if (fit in ("off", "lm")
1738
+ and pd.api.types.is_numeric_dtype(other)):
1739
+ for nm in names:
1740
+ xv_ = pd.to_numeric(data[nm], errors="coerce")
1741
+ pair = (xv_, other) if x_multi else (other, xv_)
1742
+ head = (f"Variable: {nm} with {y}" if x_multi
1743
+ else f"Variable: {x} with {nm}")
1744
+ comps.append(["", head] + xc.cor_block(
1745
+ pair[0].to_numpy(float), pair[1].to_numpy(float),
1746
+ nm if x_multi else x, y if x_multi else nm))
1747
+ say(*comps)
1748
+ return fig_s
1163
1749
 
1164
1750
  # ----- row_names: each row label identifies a single case, a
1165
1751
  # categorical axis that belongs to Chart(form="dot")
@@ -1176,21 +1762,18 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1176
1762
  f" display the values of {cont} directly with Chart():\n"
1177
1763
  f" Chart('row_names', y='{cont}', form='dot')")
1178
1764
 
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
1765
+ # sort and the segments_x/_y droplines served the dot plot XY()
1766
+ # once drew of a categorical variable, now Chart()'s
1182
1767
  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}')")
1768
+ if stat not in STAT_FUN:
1769
+ raise ValueError("stat must be one of "
1770
+ + ", ".join(f'"{k}"' for k in STAT_FUN))
1771
+ if (by is not None or facet is not None
1772
+ or isinstance(x, (list, tuple))
1773
+ or isinstance(y, (list, tuple))):
1774
+ raise ValueError(
1775
+ "stat plots one statistic of y at each value of x: "
1776
+ "no by=, facet=, or vector of variables")
1194
1777
  for nm, v in (("sort", None if sort == "0" else sort),
1195
1778
  ("segments_x", segments_x),
1196
1779
  ("segments_y", segments_y)):
@@ -1204,23 +1787,185 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1204
1787
  # chart: synthesize it rather than looking up a column
1205
1788
  def _resolve(name, arg):
1206
1789
  if isinstance(name, str) and name == ".Index":
1790
+ if ".Index" in data.columns: # numbered within series
1791
+ return data[".Index"].rename("Index")
1207
1792
  return pd.Series(range(1, len(data) + 1),
1208
1793
  index=data.index, name="Index")
1209
1794
  return get_column(data, name, arg)
1210
1795
 
1796
+ # a keyword in the x role names no variable, so it is matched
1797
+ # without regard to case: .index for the run chart, .normal,
1798
+ # .lognormal, .exponential, .uniform for a q-q chart (XY.R)
1799
+ qq_key = None
1800
+ if (isinstance(x, str) and x.startswith(".")
1801
+ and x not in data.columns):
1802
+ key = x.lower()
1803
+ if key == ".index":
1804
+ x = ".Index"
1805
+ elif key in _QQ_LAB:
1806
+ qq_key = key
1807
+ else:
1808
+ raise ValueError(
1809
+ "A name in the x role that begins with a period is a "
1810
+ f"keyword\nfor a generated variable, but {x} is not one "
1811
+ "of\nthe keywords, and is not a variable in the data.\n\n"
1812
+ "Available keywords, matched without regard to case:\n"
1813
+ " .index generates the consecutive integers of a run "
1814
+ "chart\n " + ", ".join(_QQ_LAB) + " each generates\n"
1815
+ " the theoretical quantiles of the corresponding "
1816
+ "q-q chart")
1817
+ if qq_key is not None and (isinstance(y, (list, tuple))
1818
+ or form != "scatter"):
1819
+ raise ValueError(
1820
+ "A q-q chart plots one y variable as a scatterplot "
1821
+ 'against its theoretical quantiles: one y, form="scatter"')
1822
+
1211
1823
  index_x = isinstance(x, str) and x == ".Index"
1212
1824
  index_y = isinstance(y, str) and y == ".Index"
1213
- x_ser = _resolve(x, "x")
1214
1825
  y_ser = _resolve(y, "y")
1826
+ x_ser = (pd.Series(np.nan, index=data.index, name=_QQ_LAB[qq_key])
1827
+ if qq_key is not None else _resolve(x, "x"))
1828
+ if qq_key is not None:
1829
+ x = _QQ_LAB[qq_key]
1215
1830
  if index_x: # for axis labels below
1216
1831
  x = "Index"
1217
1832
  if index_y:
1218
1833
  y = "Index"
1219
1834
 
1220
1835
  by_ser = get_column(data, by, "by") if by is not None else None
1836
+
1837
+ # ----- bubbles: pt_size names a variable that sizes each point,
1838
+ # its area by size^power, the largest radius inches across, as
1839
+ # R's bubble plot (XY.R ~489, plt.plotly bubbles)
1840
+ size_ser = size_name = None
1841
+ if isinstance(pt_size, str):
1842
+ if (facet is not None or form != "scatter"
1843
+ or index_x or qq_key is not None):
1844
+ raise ValueError(
1845
+ f"pt_size='{pt_size}' sizes the points of a "
1846
+ "scatterplot by a variable, a bubble plot,\nwhich "
1847
+ "draws on one panel: no facet, form, run chart, or "
1848
+ "q-q chart")
1849
+ size_ser = get_column(data, pt_size, "pt_size")
1850
+ if not pd.api.types.is_numeric_dtype(size_ser):
1851
+ raise TypeError(
1852
+ f"pt_size='{pt_size}' sizes the bubbles by the values "
1853
+ "of a numeric variable")
1854
+ size_name, pt_size = pt_size, 1
1855
+ elif radius is not None or power is not None:
1856
+ raise ValueError(
1857
+ "radius and power size the bubbles of a bubble plot, "
1858
+ "where pt_size names a\nnumeric variable, such as "
1859
+ "pt_size='Pre'")
1221
1860
  (facet_ser, facet_name,
1222
1861
  facet2_ser, facet2_name) = resolve_facet(data, facet, "XY")
1223
1862
 
1863
+ # q-q: the quantiles of y, regenerated within each group of by
1864
+ # and each panel of facet, so every group reads against its own
1865
+ # distribution; both axes then share one range (XY.R)
1866
+ if qq_key is not None:
1867
+ if not pd.api.types.is_numeric_dtype(y_ser):
1868
+ raise ValueError(
1869
+ "A q-q chart displays the distribution of a continuous "
1870
+ "variable,\nbut the y variable is not numeric.")
1871
+ grp = None
1872
+ for g in (by_ser, facet_ser, facet2_ser):
1873
+ if g is not None:
1874
+ gs = g.astype(str).where(g.notna())
1875
+ grp = gs if grp is None else grp + "\x1f" + gs
1876
+ yv = y_ser.to_numpy(dtype=float)
1877
+ if grp is None:
1878
+ qv = _qq_quantiles(yv, qq_key)
1879
+ else:
1880
+ qv = np.full(len(yv), np.nan)
1881
+ for lv in grp.dropna().unique():
1882
+ i = (grp == lv).to_numpy()
1883
+ if (~np.isnan(yv[i])).sum() >= 3:
1884
+ qv[i] = _qq_quantiles(yv[i], qq_key)
1885
+ x_ser = pd.Series(qv, index=data.index, name=x)
1886
+ if scale_x is None and scale_y is None:
1887
+ pr = pretty(float(np.nanmin(np.r_[qv, yv])),
1888
+ float(np.nanmax(np.r_[qv, yv])))
1889
+ scale_x = scale_y = (pr[0], pr[-1], len(pr) - 1)
1890
+
1891
+ # the report (x_console): ports of R's printers, built as the
1892
+ # display is; xy_stats() records the numbers for fig.stats
1893
+ pre, dname = xc.call_names(data_in)
1894
+ _lbls = ((getattr(data_in, "attrs", {}) or {})
1895
+ .get("variable_labels", {}) or {})
1896
+ x_lbl, y_lbl = _lbls.get(x), _lbls.get(y)
1897
+ dd_r = (xc.xy_digits(pd.to_numeric(y_ser, errors="coerce")
1898
+ .to_numpy(float), digits_user)
1899
+ if not isinstance(y, (list, tuple)) else 3)
1900
+
1901
+ def scatter_report(groups_, fit_stats_, md_lines_):
1902
+ xy_stats(groups_, x, y, fit_stats_, digits_d, by_name=by)
1903
+ if stat_lines is not None: # R reports the levels alone
1904
+ return [stat_lines]
1905
+ comps = [xc.suggest_scatter(pre, x_call, y, dname, given,
1906
+ by=by)]
1907
+ if md_lines_ is not None:
1908
+ comps.append(md_lines_)
1909
+ # correlation for no fit or a linear one, not with by, not
1910
+ # for a q-q chart (.plt.txt)
1911
+ if fit in ("off", "lm") and by is None and qq_key is None:
1912
+ for _, xg, yg in groups_:
1913
+ comps.append(xc.cor_block(xg, yg, x, y, x_lbl, y_lbl))
1914
+ # the fit that enhance= supplies is drawn, not reported (R)
1915
+ if (fit not in ("off", "null") and fit_stats_
1916
+ and not (enhance and "fit" not in given)):
1917
+ comps.append(xc.fit_text(fit_stats_, fit, fit_power, y,
1918
+ dd_r, by_name=by))
1919
+ return comps
1920
+
1921
+ def facet_report(xv_s):
1922
+ """XY.R's summary of x per grouping variable; x's own
1923
+ decimals, at least 2, as X() now gives them"""
1924
+ sd = max(xc.max_dd(xv_s), 2)
1925
+ summ = ["", f"---------- Summary Statistics for {x}"]
1926
+ for g, nm in ((by_ser, by), (facet_ser, facet_name),
1927
+ (facet2_ser, facet2_name)):
1928
+ if g is not None:
1929
+ summ += [""] + xc.vbs_summary(
1930
+ xv_s, g.to_numpy(), nm, sd,
1931
+ order=category_order(g))
1932
+ return summ
1933
+
1934
+ # stat: the statistic of y at each value of a continuous x, the
1935
+ # values first reported by level of x (XY.R, .ss.numeric(y, by=x))
1936
+ # n_bins: the mean (or median) of y within each of n_bins equal
1937
+ # bins of x, at the bin midpoints, the point sized by the count
1938
+ # and the points joined (R .plt.bins)
1939
+ bin_diam = bin_n = None
1940
+ if n_bins is not None:
1941
+ x_ser, y_ser, ylab, bin_diam, bin_n = _plt_bins(
1942
+ x_ser, y_ser, x, y, n_bins, stat, by, facet, pt_size,
1943
+ ylab, 3 if digits_d is None else digits_d, show)
1944
+ data = pd.DataFrame({x: x_ser, y: y_ser})
1945
+ stat = None
1946
+
1947
+ stat_lines = None
1948
+ if stat is not None:
1949
+ for a_, b_ in ((x, y), (y, x)):
1950
+ if not pd.api.types.is_numeric_dtype(data[a_]):
1951
+ raise ValueError(
1952
+ "stat in XY() plots a statistic of y at each value "
1953
+ "of a continuous x. For a statistic of a numerical "
1954
+ "variable across the levels of a categorical one, "
1955
+ f"use Chart():\n Chart('{a_}', y='{b_}', "
1956
+ f"stat='{stat}')")
1957
+ okx = x_ser.notna()
1958
+ stat_lines = _stat_by_levels(
1959
+ pd.to_numeric(y_ser[okx]), x_ser[okx], y, x,
1960
+ max(xc.max_dd(y_ser.to_numpy(float)) + 1, 2))
1961
+ agg = (pd.to_numeric(y_ser[okx]).groupby(x_ser[okx])
1962
+ .agg(STAT_FUN[stat]).dropna())
1963
+ data = pd.DataFrame({x: agg.index.to_numpy(float),
1964
+ y: agg.to_numpy(float)})
1965
+ x_ser, y_ser = data[x], data[y]
1966
+ if ylab is None:
1967
+ ylab = f"{STAT_LBL[stat]} of {y}"
1968
+
1224
1969
  # facet needs a categorical variable; a numeric one with more
1225
1970
  # than 25 distinct values is almost surely continuous, so stop
1226
1971
  # and point to a category conversion (XY.R ~1168). A category
@@ -1259,6 +2004,16 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1259
2004
  except (ValueError, TypeError): # not a date column
1260
2005
  pass
1261
2006
 
2007
+ # consecutive integer years become dates, Jan 1 of each, as XY.R
2008
+ # ~733, so they drive the time-series display and a forecast
2009
+ yrs = None if index_x else _as_years(x_ser)
2010
+ if yrs is not None:
2011
+ x_ser = yrs
2012
+ if show:
2013
+ print(f"\n>>> Note: Variable {x} assumed to be annual years\n"
2014
+ "Converted to a type Date variable\n"
2015
+ "If forecasting, there will be no seasonality.\n")
2016
+
1262
2017
  is_date = pd.api.types.is_datetime64_any_dtype(x_ser)
1263
2018
  # XY() displays the relationship of two continuous variables; a
1264
2019
  # categorical x or y belongs to another view, so name its call
@@ -1302,10 +2057,12 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1302
2057
  y_ser = y_ser.fillna(ts_NA)
1303
2058
 
1304
2059
  used = [s for s in (x_ser, y_ser, by_ser, facet_ser,
1305
- facet2_ser)
2060
+ facet2_ser, size_ser)
1306
2061
  if s is not None]
1307
2062
  keep = ~pd.concat(used, axis=1).isna().any(axis=1)
1308
2063
  x_ser, y_ser = x_ser[keep], y_ser[keep]
2064
+ if size_ser is not None:
2065
+ size_ser = size_ser[keep]
1309
2066
  if by_ser is not None:
1310
2067
  by_ser = by_ser[keep]
1311
2068
  if facet_ser is not None:
@@ -1313,11 +2070,50 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1313
2070
  if facet2_ser is not None:
1314
2071
  facet2_ser = facet2_ser[keep]
1315
2072
 
2073
+ if is_date and size_ser is not None:
2074
+ raise ValueError(
2075
+ f"pt_size='{size_name}' sizes the points of a scatterplot, "
2076
+ "not of a time series")
1316
2077
  if is_date and form != "scatter":
1317
2078
  raise ValueError(
1318
2079
  f'form="{form}" describes the joint density of two '
1319
2080
  "numerical variables, not a time series")
1320
2081
 
2082
+ if is_date and show:
2083
+ # the line connects the points in date order within each
2084
+ # series. A date repeated within a series, unless ts_unit
2085
+ # aggregates it, means more than one series is present;
2086
+ # otherwise rows out of order are reordered for the plot
2087
+ # below, the data unchanged. R analog: XY.R date.var check
2088
+ g_nm = [nm for nm, sr in ((by, by_ser), (facet_name, facet_ser),
2089
+ (facet2_name, facet2_ser))
2090
+ if sr is not None]
2091
+ g_ser = [sr.astype(str) for sr in (by_ser, facet_ser, facet2_ser)
2092
+ if sr is not None]
2093
+ key = (pd.concat(g_ser, axis=1).agg("\x1f".join, axis=1)
2094
+ if g_ser else pd.Series("", index=x_ser.index))
2095
+ in_grp = (f" within each level of {', '.join(g_nm)}"
2096
+ if g_nm else "")
2097
+ rep = pd.DataFrame({"k": key, "x": x_ser}).groupby(
2098
+ ["k", "x"]).size()
2099
+ if ts_unit is not None:
2100
+ pass # aggregated by period: any order
2101
+ elif (rep > 1).any():
2102
+ print(f"\n>>> Note\nThe dates of {x} repeat{in_grp}, up "
2103
+ f"to {int(rep.max())} times, so\n the data hold more "
2104
+ "than one series, and the line connects\n points "
2105
+ "from different series. Separate the series with by "
2106
+ "or facet,\n or select one with filter.\n")
2107
+ elif not all(grp.is_monotonic_increasing for _, grp in
2108
+ x_ser.groupby(key, sort=False)):
2109
+ ob = g_nm + [x]
2110
+ ob = (f"'{x}'" if len(ob) == 1
2111
+ else "[" + ", ".join(f"'{v}'" for v in ob) + "]")
2112
+ print(f"\n>>> Note\nThe rows are not in order of {x}{in_grp},"
2113
+ f"\n so they are sorted by {x} for this plot. The "
2114
+ "data frame is unchanged.\n To sort it, enter:\n"
2115
+ f" d = lp.order_by(d, by={ob})\n")
2116
+
1321
2117
  if is_date: # time series: date order
1322
2118
  od = x_ser.sort_values(kind="stable").index
1323
2119
  x_ser, y_ser = x_ser.loc[od], y_ser.loc[od]
@@ -1327,10 +2123,15 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1327
2123
  facet_ser = facet_ser.loc[od]
1328
2124
  if facet2_ser is not None:
1329
2125
  facet2_ser = facet2_ser.loc[od]
1330
- if fit != "off" or (ellipse is not False and ellipse):
1331
- raise NotImplementedError(
1332
- "fit and ellipse for a time series (date x) are "
1333
- "not yet ported")
2126
+ if ellipse is not False and ellipse:
2127
+ raise ValueError(
2128
+ "An ellipse describes the joint distribution of two "
2129
+ "continuous variables,\nnot a time series: no ellipse "
2130
+ "with a date x")
2131
+ if fit != "off" and facet_ser is not None:
2132
+ raise ValueError(
2133
+ "fit on a time series draws on a single panel: no "
2134
+ "facet=")
1334
2135
  if MD_cut > 0 or out_cut > 0:
1335
2136
  raise ValueError(
1336
2137
  "outlier flagging (MD_cut/out_cut) applies to a "
@@ -1340,17 +2141,29 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1340
2141
  "forecasting (ts_ahead) requires a date variable "
1341
2142
  "for x")
1342
2143
 
1343
- if not is_date and (ts_stack or ts_area_fill is not None):
2144
+ if not is_date and not index_x and (
2145
+ ts_stack or ts_area_fill is not None):
1344
2146
  raise ValueError(
1345
2147
  "ts_stack and ts_area_fill apply to a time series "
1346
- "(date x)")
2148
+ "(date x) or a run chart (x='.Index')")
2149
+ if index_x and ts_stack:
2150
+ raise ValueError(
2151
+ "ts_stack stacks several time series over a date x")
1347
2152
 
1348
2153
  # ----- ts aggregation (ts_unit/ts_agg) --------------------------
1349
- if is_date and ts_unit is not None:
2154
+ # facets aggregate within each panel, below
2155
+ ts_agg_done = is_date and ts_unit is not None and facet_ser is None
2156
+ if ts_agg_done:
1350
2157
  x_ser, y_ser, by_ser, ts_unit = plt_time(
1351
2158
  x_ser, y_ser, by_ser, ts_unit, ts_agg,
1352
2159
  quiet=resolve_quiet(quiet))
1353
- if is_date and (ts_stack or ts_area_fill is not None) \
2160
+ if is_date and ylab is None and facet_ser is None: # a given
2161
+ # ylab names the axis
2162
+ from .plt_time import _infer_unit
2163
+ ylab = _ts_ylab(
2164
+ y, ts_unit if ts_unit is not None
2165
+ else _infer_unit(x_ser.to_numpy()), ts_agg_done, ts_agg)
2166
+ if (is_date or index_x) and (ts_stack or ts_area_fill is not None) \
1354
2167
  and pt_size == 1:
1355
2168
  pt_size = 0 # R: no points with areas
1356
2169
 
@@ -1377,6 +2190,87 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1377
2190
  add_means = (form in ("scatter", "smooth")
1378
2191
  and facet is None)
1379
2192
 
2193
+ # ----- sunflower: petals where observations coincide ----------
2194
+ if form == "sunflower":
2195
+ if is_date:
2196
+ raise ValueError(
2197
+ 'form="sunflower" plots two continuous variables, '
2198
+ "not a time series")
2199
+ if ellipse is True:
2200
+ ellipse = 0.95
2201
+ f_col = as_plotly_color(get_option("fit_color", "#5C4032")
2202
+ if fit_color is None else fit_color)
2203
+ f_lwd = get_option("fit_lwd", 2) if fit_lwd is None else fit_lwd
2204
+ e_lvls = ([] if not ellipse else
2205
+ list(ellipse) if isinstance(ellipse, (list, tuple))
2206
+ else [ellipse])
2207
+
2208
+ def overlay(fig_, xv_, yv_, row, col):
2209
+ at = {} if row is None else dict(row=row, col=col)
2210
+ for lv in e_lvls:
2211
+ ex, ey = _ellipse_region(xv_, yv_, float(lv))
2212
+ fig_.add_trace(go.Scatter(
2213
+ x=ex, y=ey, mode="lines",
2214
+ line=dict(color=as_plotly_color(
2215
+ get_option("ellipse_color", "gray20")
2216
+ if ellipse_color is None else ellipse_color),
2217
+ width=get_option("ellipse_lwd", 1)
2218
+ if ellipse_lwd is None else ellipse_lwd),
2219
+ hoverinfo="skip", showlegend=False), **at)
2220
+ if fit not in ("off", "null"):
2221
+ if fit == "loess":
2222
+ xs_, _, f_, _ = _loess(xv_, yv_, span)
2223
+ else:
2224
+ xs_, _, f_ = _plt_fit(xv_, yv_, fit, fit_power)
2225
+ fig_.add_trace(go.Scatter(
2226
+ x=xs_, y=f_, mode="lines",
2227
+ line=dict(color=f_col, width=f_lwd),
2228
+ hoverinfo="skip", showlegend=False), **at)
2229
+
2230
+ xv_f = x_ser.to_numpy(dtype=float)
2231
+ yv_f = y_ser.to_numpy(dtype=float)
2232
+ if facet is not None:
2233
+ n_lvl = len(category_order(facet_ser))
2234
+ n_col_sf = (int(n_col) if n_col is not None else
2235
+ math.ceil(n_lvl / int(n_row)) if n_row
2236
+ is not None else math.ceil(math.sqrt(n_lvl)))
2237
+ fig = sunflower_plotly(
2238
+ xv_f, yv_f, x if xlab is None else xlab,
2239
+ y if ylab is None else ylab, main,
2240
+ facet=facet_ser.to_numpy(),
2241
+ facet_order=category_order(facet_ser),
2242
+ facet_name=facet_name,
2243
+ facet2=(None if facet2_ser is None
2244
+ else facet2_ser.to_numpy()),
2245
+ facet2_order=(None if facet2_ser is None
2246
+ else category_order(facet2_ser)),
2247
+ facet2_name=facet2_name, n_col=n_col_sf,
2248
+ pt_size=pt_size, fill=fill, color=color,
2249
+ overlay=overlay,
2250
+ digits_d=2 if digits_d is None else digits_d,
2251
+ axis_fmt=axis_fmt, axis_x_pre=axis_x_pre,
2252
+ axis_y_pre=axis_y_pre)
2253
+ xy_stats([(None, xv_f, yv_f)], x, y,
2254
+ digits_d=2 if digits_d is None else digits_d)
2255
+ say(facet_report(xv_f))
2256
+ else:
2257
+ fig = sunflower_plotly(
2258
+ xv_f, yv_f, x if xlab is None else xlab,
2259
+ y if ylab is None else ylab, main, pt_size=pt_size,
2260
+ fill=fill, color=color, overlay=overlay,
2261
+ digits_d=2 if digits_d is None else digits_d,
2262
+ axis_fmt=axis_fmt, axis_x_pre=axis_x_pre,
2263
+ axis_y_pre=axis_y_pre)
2264
+ fs = []
2265
+ if fit not in ("off", "null"):
2266
+ if fit == "loess":
2267
+ xs_, ys_, f_, _ = _loess(xv_f, yv_f, span)
2268
+ else:
2269
+ xs_, ys_, f_ = _plt_fit(xv_f, yv_f, fit, fit_power)
2270
+ fs = [(None, fit, ys_, f_, xs_)]
2271
+ say(*scatter_report([(None, xv_f, yv_f)], fs, None))
2272
+ return _apply_rotate(fig, rotate_x, rotate_y)
2273
+
1380
2274
  # ----- facet: one panel per level -----------------------------
1381
2275
  if facet is not None:
1382
2276
  facet2_arr = f2_order = None
@@ -1399,12 +2293,10 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1399
2293
  if form in ("contour", "smooth"):
1400
2294
  if ellipse is True:
1401
2295
  ellipse = 0.95
1402
- if not resolve_quiet(quiet):
1403
- print("\n".join(xy_stats(
1404
- [(None, x_ser.to_numpy(dtype=float),
1405
- y_ser.to_numpy(dtype=float))], x, y,
1406
- digits_d=2 if digits_d is None
1407
- else digits_d)))
2296
+ xy_stats([(None, x_ser.to_numpy(dtype=float),
2297
+ y_ser.to_numpy(dtype=float))], x, y,
2298
+ digits_d=2 if digits_d is None else digits_d)
2299
+ say(facet_report(x_ser.to_numpy(dtype=float)))
1408
2300
  return _apply_rotate(plt_contour_facet(
1409
2301
  x_ser.to_numpy(dtype=float),
1410
2302
  y_ser.to_numpy(dtype=float),
@@ -1433,11 +2325,57 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1433
2325
  n_col=n_col_use, axis_fmt=axis_fmt,
1434
2326
  axis_x_pre=axis_x_pre, axis_y_pre=axis_y_pre),
1435
2327
  rotate_x, rotate_y)
1436
- if is_date: # time-series panels
1437
- if by is not None:
1438
- raise NotImplementedError(
1439
- "by= with facet= for a time series is not "
1440
- "yet ported")
2328
+ # time-series panels; a filled run chart draws its panels
2329
+ # the same way, on the row index (XY.R lattice area)
2330
+ if is_date or (index_x and ts_area_fill is not None):
2331
+ # ts_unit aggregates within each panel and group; the
2332
+ # forecast is of each panel's series (XY.R, Aug 2026)
2333
+ f_arr = facet_ser.to_numpy()
2334
+ ts_by = by_ser.to_numpy() if by_ser is not None else None
2335
+ agg_done = ts_unit is not None
2336
+ if agg_done:
2337
+ parts = [s_.astype(str).to_numpy() for s_ in
2338
+ (facet_ser, facet2_ser, by_ser)
2339
+ if s_ is not None]
2340
+ key = pd.Series(["\x1f".join(t) for t in zip(*parts)],
2341
+ index=x_ser.index)
2342
+ xa, ya, ka, ts_unit = plt_time(
2343
+ x_ser, y_ser, key, ts_unit, ts_agg,
2344
+ quiet=resolve_quiet(quiet))
2345
+ cols = list(zip(*[str(k).split("\x1f")
2346
+ for k in ka.astype(str)]))
2347
+ x_ser, y_ser = xa, ya
2348
+ f_arr = np.asarray(cols[0], dtype=object)
2349
+ ci = 1
2350
+ if facet2_ser is not None:
2351
+ facet2_arr = np.asarray(cols[ci], dtype=object)
2352
+ ci += 1
2353
+ if by_ser is not None:
2354
+ ts_by = np.asarray(cols[ci], dtype=object)
2355
+ f_order = [str(v) for v in category_order(facet_ser)]
2356
+ f_arr = np.asarray(f_arr).astype(str)
2357
+ if ylab is None and is_date:
2358
+ from .plt_time import _infer_unit
2359
+ ylab = _ts_ylab(y, ts_unit if ts_unit is not None
2360
+ else _infer_unit(x_ser.to_numpy()),
2361
+ agg_done, ts_agg)
2362
+ frcsts = {}
2363
+ if ts_ahead > 0:
2364
+ xs_all = x_ser.to_numpy()
2365
+ ys_all = y_ser.to_numpy(dtype=float)
2366
+ for i_f, lv in enumerate(f_order):
2367
+ mm = f_arr == lv
2368
+ if mm.sum() < 4:
2369
+ continue
2370
+ od = np.argsort(xs_all[mm], kind="stable")
2371
+ frcsts[i_f] = plt_forecast(
2372
+ xs_all[mm][od], ys_all[mm][od], x, y,
2373
+ ts_unit=ts_unit, ts_ahead=ts_ahead,
2374
+ ts_method=ts_method, ts_source=ts_source,
2375
+ ts_error=ts_error, ts_trend=ts_trend,
2376
+ ts_seasons=ts_seasons, ts_alpha=ts_alpha,
2377
+ ts_beta=ts_beta, ts_gamma=ts_gamma,
2378
+ ts_PI=ts_PI, digits_d=digits_d)
1441
2379
  if transparency is None:
1442
2380
  transparency = get_option("trans_pt_fill", 0.10)
1443
2381
  fill0 = (get_option("pt_color", "#324E5C")
@@ -1451,7 +2389,7 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1451
2389
  ts_fig = _ts_facet(
1452
2390
  x_ser.to_numpy(),
1453
2391
  y_ser.to_numpy(dtype=float),
1454
- facet_ser.to_numpy(), category_order(facet_ser),
2392
+ f_arr, f_order,
1455
2393
  fill0, border0, pt_size, 1 - transparency,
1456
2394
  x if xlab is None else xlab,
1457
2395
  y if ylab is None else ylab,
@@ -1460,9 +2398,22 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1460
2398
  area_split=float(ts_area_split),
1461
2399
  facet_name=facet_name, facet2_arr=facet2_arr,
1462
2400
  facet2_order=f2_order,
1463
- facet2_name=facet2_name, n_col=n_col_use)
2401
+ facet2_name=facet2_name, n_col=n_col_use,
2402
+ by_arr=ts_by,
2403
+ by_order=(None if by_ser is None else
2404
+ [str(v) for v in category_order(by_ser)]),
2405
+ by_name=by, line_width=line_width,
2406
+ frcsts=frcsts, ts_PI=ts_PI)
1464
2407
  if ts_n_x_tics is not None: # date-axis tick count
1465
2408
  ts_fig.update_xaxes(nticks=int(ts_n_x_tics))
2409
+ if show:
2410
+ for i_f, fr in frcsts.items():
2411
+ print(f"\n{facet_name}: {f_order[i_f]}")
2412
+ print("\n".join(fr["report"]))
2413
+ print("Forecast\n--------")
2414
+ print(fr["forecast"].to_string(
2415
+ float_format=lambda v:
2416
+ f"{v:.{(2 if digits_d is None else digits_d) + 2}f}"))
1466
2417
  return _apply_rotate(ts_fig, rotate_x, rotate_y)
1467
2418
  by_arr = by_order = None
1468
2419
  if by_ser is not None:
@@ -1472,8 +2423,7 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1472
2423
  if fill is None:
1473
2424
  fills_f = ([get_option("pt_color", "#324E5C")]
1474
2425
  if n_grp == 1
1475
- else [BASE_COLORS[i % len(BASE_COLORS)]
1476
- for i in range(n_grp)])
2426
+ else by_colors(n_grp))
1477
2427
  else:
1478
2428
  fills_f = (list(fill)
1479
2429
  if isinstance(fill, (list, tuple))
@@ -1482,35 +2432,18 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1482
2432
  for i in range(n_grp)]
1483
2433
  if transparency is None:
1484
2434
  transparency = get_option("trans_pt_fill", 0.10)
1485
- if fit_se is None:
1486
- fit_se = 0.95 if by is None else 0
2435
+ if fit_se is None: # residuals, not a band
2436
+ fit_se = 0 if fit_errors else (0.95 if by is None else 0)
1487
2437
  se_lv = [lv for lv in (
1488
2438
  fit_se if isinstance(fit_se, (list, tuple))
1489
2439
  else [fit_se]) if lv]
1490
2440
  if ellipse is True:
1491
2441
  ellipse = 0.95
1492
- if not resolve_quiet(quiet): # overall relationship
1493
- dd_s = 2 if digits_d is None else digits_d
1494
- xv_s = x_ser.to_numpy(dtype=float)
1495
- print("\n".join(xy_stats(
1496
- [(None, xv_s, y_ser.to_numpy(dtype=float))],
1497
- x, y, digits_d=dd_s)))
1498
- # summary table per grouping variable, ~ XY.R's
1499
- # .vbs_summary_table pivot block
1500
- print(f"\n---------- Summary Statistics for {x}")
1501
- if by_ser is not None:
1502
- print()
1503
- print(facet_summary(xv_s, by_ser.to_numpy(),
1504
- by, dd_s))
1505
- print()
1506
- print(facet_summary(xv_s, facet_ser.to_numpy(),
1507
- facet_name, dd_s))
1508
- if facet2_ser is not None:
1509
- print()
1510
- print(facet_summary(xv_s,
1511
- facet2_ser.to_numpy(),
1512
- facet2_name, dd_s))
1513
- return _apply_rotate(_xy_facet(
2442
+ xv_s = x_ser.to_numpy(dtype=float)
2443
+ xy_stats([(None, xv_s, y_ser.to_numpy(dtype=float))],
2444
+ x, y, digits_d=2 if digits_d is None else digits_d)
2445
+ say(facet_report(xv_s))
2446
+ fig_f = _apply_rotate(_xy_facet(
1514
2447
  x_ser.to_numpy(dtype=float),
1515
2448
  y_ser.to_numpy(dtype=float),
1516
2449
  by_arr, by_order,
@@ -1533,8 +2466,12 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1533
2466
  y if ylab is None else ylab,
1534
2467
  main, 2 if digits_d is None else digits_d,
1535
2468
  facet2_arr=facet2_arr, facet2_order=f2_order,
1536
- facet2_name=facet2_name, n_col=n_col_use),
2469
+ facet2_name=facet2_name, n_col=n_col_use,
2470
+ fit_errors=fit_errors),
1537
2471
  rotate_x, rotate_y)
2472
+ if qq_key is not None:
2473
+ _qq_reference(fig_f, fit_color, fit_lwd)
2474
+ return fig_f
1538
2475
 
1539
2476
  # ----- groups and colors --------------------------------------
1540
2477
  xv = x_ser.to_numpy() if is_date \
@@ -1548,6 +2485,27 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1548
2485
  groups = [(nm, xv[bv == nm], yv[bv == nm])
1549
2486
  for nm in by_order]
1550
2487
 
2488
+ # bubble diameters in pixels: 2 * (v / max)^power * radius at
2489
+ # 96 dpi, radius .12 inches by default (R plt.plotly bubbles)
2490
+ sizes = None
2491
+ if bin_diam is not None: # bins sized by their counts
2492
+ sizes, size_name = [(bin_diam, bin_n)], "n"
2493
+ if size_ser is not None:
2494
+ sv = size_ser.to_numpy(dtype=float)
2495
+ rad = 0.12 if radius is None else float(radius)
2496
+ pw = 0.5 if power is None else float(power)
2497
+ sz = np.clip(sv, 0, None) ** pw
2498
+ mx = float(sz.max()) if len(sz) else 0.0
2499
+ diam = (2 * (sz / mx) * rad * 96 if mx > 0
2500
+ else np.zeros_like(sz))
2501
+ sizes = ([(diam, sv)] if by_ser is None else
2502
+ [(diam[bv == nm], sv[bv == nm]) for nm in by_order])
2503
+ if show:
2504
+ print("\nSome Parameter values (can be manually set)")
2505
+ print("-" * 55)
2506
+ print(f"radius: {rad:.2f} size of largest bubble")
2507
+ print(f"power: {pw:.2f} relative bubble sizes")
2508
+
1551
2509
  # ----- jitter: the display coordinates only --------------------
1552
2510
  # auto-jitter an axis with few discrete values (R plt.main.R
1553
2511
  # ~634-659); the fit, ellipse, and statistics use the
@@ -1592,9 +2550,9 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1592
2550
  n_grp = len(groups)
1593
2551
  if fill is None:
1594
2552
  fills = ([get_option("pt_color", "#324E5C")] if n_grp == 1
1595
- else [BASE_COLORS[i % len(BASE_COLORS)]
1596
- for i in range(n_grp)])
2553
+ else by_colors(n_grp))
1597
2554
  else:
2555
+ fill = _expand_palette(fill, n_grp)
1598
2556
  fills = list(fill) if isinstance(fill, (list, tuple)) \
1599
2557
  else [fill]
1600
2558
  borders = None if color is None else color
@@ -1616,6 +2574,7 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1616
2574
  "ts_stack requires the same dates at every "
1617
2575
  "level of by=")
1618
2576
  if ts_area_fill is not None:
2577
+ ts_area_fill = _expand_palette(ts_area_fill, n_grp)
1619
2578
  a_base = (list(ts_area_fill)
1620
2579
  if isinstance(ts_area_fill, (list, tuple))
1621
2580
  else [ts_area_fill])
@@ -1642,7 +2601,7 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1642
2601
  cum = yc
1643
2602
  groups = stacked
1644
2603
  disp_groups = stacked
1645
- elif is_date and ts_area_fill is not None:
2604
+ elif (is_date or index_x) and ts_area_fill is not None:
1646
2605
  nm0, xg, yg = groups[0]
1647
2606
  lo, hi = float(yg.min()), float(yg.max())
1648
2607
  pad_a = 0.04 * (hi - lo) if hi > lo else 1.0
@@ -1670,17 +2629,22 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1670
2629
  fit_lines, se_polys, fit_stats = [], [], []
1671
2630
  err_lines = [] # fit_errors residual segments
1672
2631
  if fit != "off":
1673
- for nm, xg, yg in groups:
2632
+ # a date x is fit as days, the fit drawn back on the dates
2633
+ day = 86400e9 # nanoseconds
2634
+ for nm, xg, yg in (
2635
+ [(n_, xg_.astype("datetime64[ns]").astype("int64") / day,
2636
+ yg_) for n_, xg_, yg_ in groups]
2637
+ if is_date else groups):
1674
2638
  if len(xg) < 2:
1675
2639
  continue
1676
2640
  if fit == "loess": # R: bands for lm and loess
1677
2641
  xs, ys_s, f, se_f = _loess(xg, yg, span)
1678
2642
  fit_lines.append({"name": nm, "x": xs, "y": f})
1679
- fit_stats.append((nm, fit, ys_s, f))
2643
+ fit_stats.append((nm, fit, ys_s, f, xs))
1680
2644
  if fit_errors:
1681
2645
  err_lines.append((xs, ys_s, f))
1682
2646
  for lv in se_levels:
1683
- tq = sps.t.ppf((1 + lv) / 2, len(xs) - 1)
2647
+ tq = sps.t.ppf((1 + lv) / 2, _loess.df)
1684
2648
  se_polys.append((
1685
2649
  np.concatenate([xs, xs[::-1]]),
1686
2650
  np.concatenate([f + tq * se_f,
@@ -1690,28 +2654,41 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1690
2654
  okf = np.isfinite(f) # exp/log back-transform NaN
1691
2655
  fit_lines.append({"name": nm, "x": xs[okf],
1692
2656
  "y": f[okf]})
1693
- fit_stats.append((nm, fit, ys_s[okf], f[okf]))
2657
+ fit_stats.append((nm, fit, ys_s[okf], f[okf], xs[okf]))
1694
2658
  if fit_errors:
1695
2659
  err_lines.append((xs[okf], ys_s[okf], f[okf]))
1696
2660
  if fit == "lm":
1697
2661
  for lv in se_levels:
1698
- se_polys.append(_se_band(xs, ys_s, f, lv))
2662
+ band = _se_band(xs, ys_s, f, lv)
2663
+ if band is not None:
2664
+ se_polys.append(band)
2665
+
2666
+ if is_date and fit != "off": # days back to dates
2667
+ def _dates(v):
2668
+ v = np.asarray(v, dtype=float)
2669
+ return (v * day).round().astype("int64").astype(
2670
+ "datetime64[ns]")
2671
+ for fl in fit_lines:
2672
+ fl["x"] = _dates(fl["x"])
2673
+ se_polys = [(_dates(bx), by_) for bx, by_ in se_polys]
2674
+ err_lines = [(_dates(xe), ye, fe) for xe, ye, fe in err_lines]
1699
2675
 
1700
2676
  # ----- ellipses ------------------------------------------------
1701
2677
  if ellipse is True:
1702
2678
  ellipse = 0.95
1703
2679
  ellipses = []
1704
- if ellipse:
1705
- if not 0 < ellipse < 1:
1706
- raise ValueError("ellipse is the confidence level of "
1707
- "a data ellipse, between 0 and 1")
2680
+ e_levels = _ellipse_levels(ellipse)
2681
+ for nm, xg, yg in (groups if e_levels else []):
2682
+ if len(xg) < 3:
2683
+ who = "" if nm is None else f" for group '{nm}'"
2684
+ raise ValueError(
2685
+ f"ellipse: need at least 3 points{who}, "
2686
+ f"found {len(xg)}")
2687
+ # level by level, each over the groups, so ellipse k belongs to
2688
+ # group k % n_grp
2689
+ for lv in e_levels:
1708
2690
  for nm, xg, yg in groups:
1709
- if len(xg) < 3:
1710
- who = "" if nm is None else f" for group '{nm}'"
1711
- raise ValueError(
1712
- f"ellipse: need at least 3 points{who}, "
1713
- f"found {len(xg)}")
1714
- ellipses.append(_ellipse_region(xg, yg, ellipse))
2691
+ ellipses.append(_ellipse_region(xg, yg, lv))
1715
2692
 
1716
2693
  if digits_d is None:
1717
2694
  digits_d = 2
@@ -1740,11 +2717,7 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1740
2717
  axis_fmt=axis_fmt, axis_x_pre=axis_x_pre,
1741
2718
  axis_y_pre=axis_y_pre)
1742
2719
  _apply_rotate(fig, rotate_x, rotate_y)
1743
- if not resolve_quiet(quiet):
1744
- print("\n".join(xy_stats(groups, x, y, fit_stats,
1745
- digits_d)))
1746
- if md_lines is not None:
1747
- print("\n" + "\n".join(md_lines))
2720
+ say(*scatter_report(groups, fit_stats, md_lines))
1748
2721
  return fig
1749
2722
 
1750
2723
  # ----- forecast (date x, ts_source="classic") -------------------
@@ -1767,7 +2740,7 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1767
2740
  frcst["y_lwr"], frcst["y_upr"]] if frcst else []))
1768
2741
  if scale_y is not None: # explicit y scale
1769
2742
  axT2 = np.linspace(float(scale_y[0]), float(scale_y[1]),
1770
- int(scale_y[2]))
2743
+ int(scale_y[2]) + 1) # n intervals
1771
2744
  else:
1772
2745
  axT2 = pretty(float(np.nanmin(ys_all)),
1773
2746
  float(np.nanmax(ys_all)))
@@ -1783,7 +2756,7 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1783
2756
  if scale_x is not None: # explicit x scale
1784
2757
  axT1 = np.linspace(float(scale_x[0]),
1785
2758
  float(scale_x[1]),
1786
- int(scale_x[2]))
2759
+ int(scale_x[2]) + 1) # n intervals
1787
2760
  else:
1788
2761
  axT1 = pretty(float(np.nanmin(xs_all)),
1789
2762
  float(np.nanmax(xs_all)))
@@ -1832,19 +2805,19 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1832
2805
  _apply_rotate(fig, rotate_x, rotate_y)
1833
2806
  if add_means:
1834
2807
  _add_means(fig, xv, yv)
1835
- if not resolve_quiet(quiet):
1836
- print("\n".join(xy_stats(groups, x, y, fit_stats,
1837
- digits_d)))
2808
+ say(*scatter_report(groups, fit_stats, md_lines))
1838
2809
  return fig
1839
2810
 
1840
2811
  fig = plt_plotly(
1841
2812
  disp_groups, by_name=by,
1842
2813
  fill=fills, border=borders, shape=pt_shape, pt_size=pt_size,
2814
+ sizes=sizes, size_name=size_name,
1843
2815
  x_lab=x if xlab is None else xlab,
1844
2816
  y_lab=y if ylab is None else ylab,
1845
2817
  ax=ax, gridT1=gridT1, gridT2=ax["axT2"],
1846
2818
  main=main, digits_d=digits_d,
1847
- connect=is_date or show_runs or index_x, ln_width=line_width,
2819
+ connect=(is_date or show_runs or index_x
2820
+ or bin_diam is not None), ln_width=line_width,
1848
2821
  is_date=is_date,
1849
2822
  pt_opacity=1 - transparency,
1850
2823
  area_polys=area_polys,
@@ -1882,6 +2855,17 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1882
2855
  # runs test: connect the points and report the run analysis
1883
2856
  # below; the run chart's center line is the median, so a run
1884
2857
  # defaults center_line to "median" (XY.R ~809)
2858
+ # unspecified, a run chart shows its median unless the values
2859
+ # seldom cross their mean, fewer than 15% of the steps (XY.R ~2382)
2860
+ if center_line is None:
2861
+ center_line = "off"
2862
+ yc = np.asarray(yv, dtype=float)
2863
+ yc = yc[np.isfinite(yc)]
2864
+ if index_x and len(yc) > 1:
2865
+ m_c = yc.mean()
2866
+ n_change = int(np.sum((yc[1:] > m_c) != (yc[:-1] > m_c)))
2867
+ if n_change / (len(yc) - 1) >= 0.15:
2868
+ center_line = "median"
1885
2869
  run_lines = None
1886
2870
  if show_runs:
1887
2871
  m_run, run_lines = _run_analysis(yv, digits_d, True)
@@ -1897,7 +2881,7 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1897
2881
  m_cl, lbl = float(np.median(yv)), " median"
1898
2882
  else: # "zero": line but no label
1899
2883
  m_cl, lbl = 0.0, ""
1900
- gray = to_hex("gray50")
2884
+ gray = as_plotly_color("gray50")
1901
2885
  fig.add_hline(y=m_cl, line=dict(color=gray, dash="dash",
1902
2886
  width=1))
1903
2887
  if lbl:
@@ -1911,6 +2895,8 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1911
2895
 
1912
2896
  if add_means: # enhance: mean crosshair
1913
2897
  _add_means(fig, xv, yv)
2898
+ if qq_key is not None:
2899
+ _qq_reference(fig, fit_color, fit_lwd)
1914
2900
 
1915
2901
  if scale_x is not None: # explicit axis ranges
1916
2902
  fig.update_xaxes(range=[float(scale_x[0]),
@@ -1960,7 +2946,7 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1960
2946
  fig.add_trace(go.Scatter(
1961
2947
  x=xs2, y=f2, mode="lines",
1962
2948
  name="Fit (no outliers)",
1963
- line=dict(color=to_hex(
2949
+ line=dict(color=as_plotly_color(
1964
2950
  get_option("fit_color", "#5C4032")
1965
2951
  if fit_color is None else fit_color),
1966
2952
  width=get_option("fit_lwd", 2)
@@ -1968,28 +2954,43 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
1968
2954
  dash="dash"),
1969
2955
  showlegend=True))
1970
2956
 
2957
+ # the series displays: suggestions (with the forecast report
2958
+ # and table when one is made), the run analysis of a run chart
2959
+ ser_extra = "".join(
2960
+ f", {k}={v!r}" if isinstance(v, str) else f", {k}={v}"
2961
+ for k, v in (("by", by), ("ts_unit", ts_unit),
2962
+ ("ts_ahead", ts_ahead or None))
2963
+ if v is not None).replace("'", '"')
1971
2964
  if frcst is not None:
1972
2965
  _forecast_traces(fig, frcst, ts_PI)
1973
- if not resolve_quiet(quiet):
2966
+ if show:
1974
2967
  for ln in frcst["report"]:
1975
2968
  print(ln)
1976
2969
  if ts_fitted: # observed vs model-fit table
1977
2970
  print("\n".join(_ts_fitted_lines(
1978
2971
  frcst, yv, x, y, ts_unit, digits_d)))
1979
- print("\nForecast\n--------")
2972
+ say(xc.suggest_series(pre, x, y, dname, given,
2973
+ ser_extra, pt_size=pt_size))
2974
+ print("Forecast\n--------")
1980
2975
  print(frcst["forecast"].to_string(
1981
2976
  float_format=lambda v: f"{v:.{digits_d + 2}f}"))
1982
- elif not is_date and not resolve_quiet(quiet):
1983
- print("\n".join(xy_stats(groups, x, y, fit_stats,
1984
- digits_d)))
1985
- if fit_new is not None and fit in _FIT_NEW_OK:
2977
+ elif is_date:
2978
+ say(xc.suggest_series(pre, x, y, dname, given, ser_extra,
2979
+ pt_size=pt_size))
2980
+ elif index_x: # run chart: R's run analysis
2981
+ if run_lines is None:
2982
+ _, run_lines = _run_analysis(yv, digits_d, False)
2983
+ say(xc.suggest_series(pre, ".Index", y, dname, given,
2984
+ ser_extra, run=True, pt_size=pt_size),
2985
+ xc.run_summary(yv, dd_r), run_lines[1:])
2986
+ elif bin_diam is not None: # the bin tables are the report
2987
+ pass
2988
+ else:
2989
+ say(*scatter_report(groups, fit_stats, md_lines))
2990
+ if fit_new is not None and fit in _FIT_NEW_OK and show:
1986
2991
  print("\n".join(_fit_new_table(
1987
2992
  groups, fit, fit_power, fit_new, x, y, digits_d)))
1988
- if run_lines is not None:
1989
- print("\n".join(run_lines))
1990
- if md_lines is not None:
1991
- print("\n" + "\n".join(md_lines))
1992
- if jitter_x > 0 or jitter_y > 0:
2993
+ if (jitter_x > 0 or jitter_y > 0) and show:
1993
2994
  print("\nSome Parameter values (can be manually set)")
1994
2995
  print("-" * 55)
1995
2996
  print(f"size: {float(pt_size):.2f} "
@@ -2004,4 +3005,96 @@ def XY(x, y=None, data=None, filter=None, by=None, facet=None,
2004
3005
 
2005
3006
 
2006
3007
  # font_size= scales all text of the returned figure
2007
- XY = font_scaled(XY)
3008
+ from .stats_out import attach_stats as _attach_stats # noqa: E402
3009
+ from .style import with_theme as _with_theme # noqa: E402
3010
+ from .utils import file_output # noqa: E402
3011
+ def _banking(x, y, data, filter, facet):
3012
+ """lattice's aspect="xy": banking to 45 degrees, the median of
3013
+ |dx/dy| between x-adjacent points, each difference scaled by the
3014
+ range of its variable, the median over the facet panels.
3015
+ R analog: lattice::banking() of prepanel.default.xyplot"""
3016
+ d = data.query(filter) if filter is not None else data
3017
+ fcols = ([] if facet is None else
3018
+ [facet] if isinstance(facet, str) else list(facet))
3019
+ d = d[[x, y] + fcols].dropna()
3020
+ xn = d[x]
3021
+ if pd.api.types.is_datetime64_any_dtype(xn):
3022
+ xn = xn.astype("datetime64[ns]").astype("int64")
3023
+ xn = xn.to_numpy(dtype=float)
3024
+ yn = d[y].to_numpy(dtype=float)
3025
+ rx, ry = np.ptp(xn), np.ptp(yn)
3026
+ if rx == 0 or ry == 0:
3027
+ return 1.0
3028
+ keys = ([np.zeros(len(d), int)] if not fcols else
3029
+ [d[c].astype(str).to_numpy() for c in fcols])
3030
+ key = pd.Series(list(zip(*keys)))
3031
+ banks = []
3032
+ for k in key.unique():
3033
+ m = (key == k).to_numpy()
3034
+ od = np.argsort(xn[m], kind="stable")
3035
+ dx = np.diff(xn[m][od]) / rx
3036
+ dy = np.diff(yn[m][od]) / ry
3037
+ ok = (dx != 0) & (dy != 0)
3038
+ if ok.any():
3039
+ banks.append(float(np.median(np.abs(dx[ok] / dy[ok]))))
3040
+ return float(np.median(banks)) if banks else 1.0
3041
+
3042
+
3043
+ def _set_aspect(fig, ratio, width=700):
3044
+ """Size the figure so each panel's height is ratio times its
3045
+ width, from the panel's share of the plotting area and the
3046
+ margins, at width pixels unless the figure sets its own."""
3047
+ lay = fig.layout
3048
+ W = lay.width or width
3049
+ m = lay.margin
3050
+ ml = 80 if m.l is None else m.l
3051
+ mr = 80 if m.r is None else m.r
3052
+ mt = 100 if m.t is None else m.t
3053
+ mb = 80 if m.b is None else m.b
3054
+ xd = lay.xaxis.domain or (0, 1)
3055
+ yd = lay.yaxis.domain or (0, 1)
3056
+ panel_w = (xd[1] - xd[0]) * (W - ml - mr)
3057
+ H = ratio * panel_w / (yd[1] - yd[0]) + mt + mb
3058
+ fig.update_layout(width=W, height=round(H))
3059
+
3060
+
3061
+ def _with_aspect(func):
3062
+ """aspect= the shape of the panels as their height over width,
3063
+ lattice's aspect: a number, "xy" to bank the median slope to 45
3064
+ degrees, or "fill", the default, the panels filling the figure."""
3065
+ import functools
3066
+ import inspect
3067
+ sig = inspect.signature(func)
3068
+
3069
+ @functools.wraps(func)
3070
+ def wrapper(*args, aspect="fill", **kwargs):
3071
+ if not (aspect in ("fill", "xy") or (
3072
+ isinstance(aspect, (int, float))
3073
+ and not isinstance(aspect, bool) and aspect > 0)):
3074
+ raise ValueError(
3075
+ 'aspect is the panel height over its width: a positive '
3076
+ 'number, "xy" to bank\nto 45 degrees, or "fill"')
3077
+ fig = func(*args, **kwargs)
3078
+ if aspect == "fill" or not hasattr(fig, "layout"):
3079
+ return fig
3080
+ if aspect == "xy":
3081
+ b = sig.bind_partial(*args, **kwargs).arguments
3082
+ x, y = b.get("x"), b.get("y")
3083
+ if not (isinstance(x, str) and isinstance(y, str)
3084
+ and b.get("data") is not None
3085
+ and x in b["data"] and y in b["data"]):
3086
+ raise ValueError(
3087
+ 'aspect="xy" banks the slopes of one x variable '
3088
+ "and one y variable of the data")
3089
+ ratio = _banking(x, y, b["data"], b.get("filter"),
3090
+ b.get("facet"))
3091
+ else:
3092
+ ratio = float(aspect)
3093
+ _set_aspect(fig, ratio)
3094
+ return fig
3095
+
3096
+ return wrapper
3097
+
3098
+
3099
+ XY = file_output(font_scaled(_attach_stats(_with_theme(_with_aspect(XY)),
3100
+ capture=True)))