coeftable 0.4.0__tar.gz → 0.5.0__tar.gz

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 (46) hide show
  1. coeftable-0.5.0/AGENTS.md +7 -0
  2. {coeftable-0.4.0 → coeftable-0.5.0}/CHANGELOG.md +15 -3
  3. {coeftable-0.4.0 → coeftable-0.5.0}/PKG-INFO +31 -1
  4. {coeftable-0.4.0 → coeftable-0.5.0}/README.md +30 -0
  5. coeftable-0.5.0/docs/images/forest-robust-autoscale.png +0 -0
  6. {coeftable-0.4.0 → coeftable-0.5.0}/src/coeftable/_version.py +2 -2
  7. {coeftable-0.4.0 → coeftable-0.5.0}/src/coeftable/spec.py +48 -5
  8. {coeftable-0.4.0 → coeftable-0.5.0}/src/coeftable/svg.py +92 -20
  9. {coeftable-0.4.0 → coeftable-0.5.0}/tests/test_frame.py +88 -0
  10. {coeftable-0.4.0 → coeftable-0.5.0}/tests/test_svg.py +36 -0
  11. {coeftable-0.4.0 → coeftable-0.5.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  12. {coeftable-0.4.0 → coeftable-0.5.0}/.github/release.yml +0 -0
  13. {coeftable-0.4.0 → coeftable-0.5.0}/.github/workflows/ci.yml +0 -0
  14. {coeftable-0.4.0 → coeftable-0.5.0}/.github/workflows/post-release.yml +0 -0
  15. {coeftable-0.4.0 → coeftable-0.5.0}/.github/workflows/publish.yml +0 -0
  16. {coeftable-0.4.0 → coeftable-0.5.0}/.github/workflows/release.yml +0 -0
  17. {coeftable-0.4.0 → coeftable-0.5.0}/.gitignore +0 -0
  18. {coeftable-0.4.0 → coeftable-0.5.0}/.pre-commit-config.yaml +0 -0
  19. {coeftable-0.4.0 → coeftable-0.5.0}/LICENSE +0 -0
  20. {coeftable-0.4.0 → coeftable-0.5.0}/Makefile +0 -0
  21. {coeftable-0.4.0 → coeftable-0.5.0}/docs/images/example.png +0 -0
  22. {coeftable-0.4.0 → coeftable-0.5.0}/docs/images/trend-example.png +0 -0
  23. {coeftable-0.4.0 → coeftable-0.5.0}/noxfile.py +0 -0
  24. {coeftable-0.4.0 → coeftable-0.5.0}/pyproject.toml +0 -0
  25. {coeftable-0.4.0 → coeftable-0.5.0}/src/coeftable/__init__.py +0 -0
  26. {coeftable-0.4.0 → coeftable-0.5.0}/src/coeftable/_axis.py +0 -0
  27. {coeftable-0.4.0 → coeftable-0.5.0}/src/coeftable/annotations.py +0 -0
  28. {coeftable-0.4.0 → coeftable-0.5.0}/src/coeftable/collapsible.py +0 -0
  29. {coeftable-0.4.0 → coeftable-0.5.0}/src/coeftable/errors.py +0 -0
  30. {coeftable-0.4.0 → coeftable-0.5.0}/src/coeftable/format.py +0 -0
  31. {coeftable-0.4.0 → coeftable-0.5.0}/src/coeftable/frame.py +0 -0
  32. {coeftable-0.4.0 → coeftable-0.5.0}/src/coeftable/grid.py +0 -0
  33. {coeftable-0.4.0 → coeftable-0.5.0}/src/coeftable/render.py +0 -0
  34. {coeftable-0.4.0 → coeftable-0.5.0}/src/coeftable/series.py +0 -0
  35. {coeftable-0.4.0 → coeftable-0.5.0}/src/coeftable/theme.py +0 -0
  36. {coeftable-0.4.0 → coeftable-0.5.0}/tests/test_annotations.py +0 -0
  37. {coeftable-0.4.0 → coeftable-0.5.0}/tests/test_collapsible.py +0 -0
  38. {coeftable-0.4.0 → coeftable-0.5.0}/tests/test_format.py +0 -0
  39. {coeftable-0.4.0 → coeftable-0.5.0}/tests/test_package.py +0 -0
  40. {coeftable-0.4.0 → coeftable-0.5.0}/tests/test_public_api.py +0 -0
  41. {coeftable-0.4.0 → coeftable-0.5.0}/tests/test_render.py +0 -0
  42. {coeftable-0.4.0 → coeftable-0.5.0}/tests/test_series.py +0 -0
  43. {coeftable-0.4.0 → coeftable-0.5.0}/tests/test_sparkline.py +0 -0
  44. {coeftable-0.4.0 → coeftable-0.5.0}/tests/test_spec.py +0 -0
  45. {coeftable-0.4.0 → coeftable-0.5.0}/tests/test_theme.py +0 -0
  46. {coeftable-0.4.0 → coeftable-0.5.0}/uv.lock +0 -0
@@ -0,0 +1,7 @@
1
+ # Agent guidelines
2
+
3
+ ## Changelog
4
+
5
+ Never edit `CHANGELOG.md`. It is generated by release automation
6
+ (`.github/release.yml`) from merged PR titles; Describe changes in the PR
7
+ description instead.
@@ -1,7 +1,18 @@
1
- ## Unreleased
1
+ <a id="v0.4.0"></a>
2
+ # [v0.4.0](https://github.com/kylejcaron/coeftable/releases/tag/v0.4.0) - 2026-08-14
3
+
4
+ <!-- Release notes generated using configuration in .github/release.yml at v0.4.0 -->
5
+
6
+ ## What's Changed
7
+ ### Maintenance
8
+ * build: align hatchling metadata floor by [@kylejcaron](https://github.com/kylejcaron) in [#30](https://github.com/kylejcaron/coeftable/pull/30)
9
+ * feature: support plot annotations by [@kylejcaron](https://github.com/kylejcaron) in [#31](https://github.com/kylejcaron/coeftable/pull/31)
10
+
11
+
12
+ **Full Changelog**: https://github.com/kylejcaron/coeftable/compare/v0.3.1...v0.4.0
13
+
14
+ [Changes][v0.4.0]
2
15
 
3
- ### Features
4
- - Add typed rule and band annotations to forest plots and sparklines, including row-specific field binding and domain-aware layering.
5
16
 
6
17
  <a id="v0.3.1"></a>
7
18
  # [v0.3.1](https://github.com/kylejcaron/coeftable/releases/tag/v0.3.1) - 2026-08-14
@@ -97,6 +108,7 @@
97
108
  [Changes][v0.1.0]
98
109
 
99
110
 
111
+ [v0.4.0]: https://github.com/kylejcaron/coeftable/compare/v0.3.1...v0.4.0
100
112
  [v0.3.1]: https://github.com/kylejcaron/coeftable/compare/v0.3.0...v0.3.1
101
113
  [v0.3.0]: https://github.com/kylejcaron/coeftable/compare/v0.2.1...v0.3.0
102
114
  [v0.2.1]: https://github.com/kylejcaron/coeftable/compare/v0.2.0...v0.2.1
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: coeftable
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Publication-quality summary tables for estimates with uncertainty.
5
5
  Project-URL: Homepage, https://github.com/kylejcaron/coeftable
6
6
  Project-URL: Repository, https://github.com/kylejcaron/coeftable
@@ -414,6 +414,36 @@ trend = pl.DataFrame(
414
414
  )
415
415
  ```
416
416
 
417
+ **Forest plots take `autoscale` too.** A single extreme interval (say a
418
+ +3000% lift among sub-1% metrics) otherwise stretches the shared domain
419
+ and flattens every other bar:
420
+
421
+ ```python
422
+ lifts = pl.DataFrame(
423
+ {
424
+ "metric": ["Checkout CVR", "Signup Rate", "Retention D7", "AOV", "Rare Event"],
425
+ "rel": [0.03, -0.05, 0.08, 0.02, 30.0],
426
+ "rel_lb": [0.01, -0.09, 0.04, -0.01, 22.0],
427
+ "rel_ub": [0.05, -0.01, 0.12, 0.05, 41.0],
428
+ }
429
+ )
430
+
431
+ (
432
+ ct.CoefTable(lifts, rows="metric")
433
+ .estimate("Lift %", "rel", ci=("rel_lb", "rel_ub"), fmt=ct.Percent(signed=True))
434
+ # "robust" fits an IQR/Tukey fence over the pooled values instead of
435
+ # the raw min/max, so the outlier stops dictating the axis. Composes
436
+ # with symmetric= (fence first, then mirror around ref); ylim= still
437
+ # overrides everything.
438
+ .forest("Lift Plot", of="Lift %", ref=0.0, autoscale="robust")
439
+ )
440
+ ```
441
+
442
+ A discounted interval is never hidden: bars in a clipping domain plot
443
+ against a slightly inset region and the clipped interval continues into
444
+ the reserved margin as a gradient fade, reading as "extends beyond the
445
+ axis". Buckets with nothing clipped render exactly as before.
446
+
417
447
  **No reference, or a hidden one.** `ref` also drives the dashed line and
418
448
  colour resolution; two ways to opt out, for data with no meaningful
419
449
  zero (revenue, durations, absolute counts):
@@ -360,6 +360,36 @@ trend = pl.DataFrame(
360
360
  )
361
361
  ```
362
362
 
363
+ **Forest plots take `autoscale` too.** A single extreme interval (say a
364
+ +3000% lift among sub-1% metrics) otherwise stretches the shared domain
365
+ and flattens every other bar:
366
+
367
+ ```python
368
+ lifts = pl.DataFrame(
369
+ {
370
+ "metric": ["Checkout CVR", "Signup Rate", "Retention D7", "AOV", "Rare Event"],
371
+ "rel": [0.03, -0.05, 0.08, 0.02, 30.0],
372
+ "rel_lb": [0.01, -0.09, 0.04, -0.01, 22.0],
373
+ "rel_ub": [0.05, -0.01, 0.12, 0.05, 41.0],
374
+ }
375
+ )
376
+
377
+ (
378
+ ct.CoefTable(lifts, rows="metric")
379
+ .estimate("Lift %", "rel", ci=("rel_lb", "rel_ub"), fmt=ct.Percent(signed=True))
380
+ # "robust" fits an IQR/Tukey fence over the pooled values instead of
381
+ # the raw min/max, so the outlier stops dictating the axis. Composes
382
+ # with symmetric= (fence first, then mirror around ref); ylim= still
383
+ # overrides everything.
384
+ .forest("Lift Plot", of="Lift %", ref=0.0, autoscale="robust")
385
+ )
386
+ ```
387
+
388
+ A discounted interval is never hidden: bars in a clipping domain plot
389
+ against a slightly inset region and the clipped interval continues into
390
+ the reserved margin as a gradient fade, reading as "extends beyond the
391
+ axis". Buckets with nothing clipped render exactly as before.
392
+
363
393
  **No reference, or a hidden one.** `ref` also drives the dashed line and
364
394
  colour resolution; two ways to opt out, for data with no meaningful
365
395
  zero (revenue, durations, absolute counts):
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.4.0'
22
- __version_tuple__ = version_tuple = (0, 4, 0)
21
+ __version__ = version = '0.5.0'
22
+ __version_tuple__ = version_tuple = (0, 5, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -30,6 +30,7 @@ from coeftable.format import (
30
30
  render_interval,
31
31
  )
32
32
  from coeftable.svg import (
33
+ CLIP_MARGIN,
33
34
  Trace,
34
35
  forest_axis,
35
36
  forest_bar,
@@ -378,22 +379,24 @@ def _bucket_domain(
378
379
  max_domain: float | None,
379
380
  autoscale: Autoscale = "tight",
380
381
  required: Sequence[float] = (),
382
+ symmetric: bool = False,
381
383
  ) -> tuple[float, float]:
382
- """Resolve one domain bucket for `Sparkline.prepare`.
384
+ """Resolve one domain bucket for `Sparkline.prepare` or `Forest.prepare`.
383
385
 
384
386
  `override` wins outright; otherwise a tight fit uses all `values` and
385
387
  a robust fit first applies an IQR/Tukey fence. Required annotation
386
388
  coordinates are joined to those retained data inputs before one padding
387
389
  pass, so a coordinate within the retained data extent leaves the
388
390
  automatic fit unchanged. `max_domain`, when set, then narrows the result
389
- around `ref`.
391
+ around `ref`. `symmetric=True` mirrors the padded fit around `ref`
392
+ (`Forest`'s option; requires `ref`, enforced by `_pad_domain`).
390
393
  """
391
394
  if override is not None:
392
395
  return override
393
396
  fit_values = _robust_inliers(values) if autoscale == "robust" else values
394
397
  finite_required = _finite(list(required)) if required else []
395
398
  inputs = [*fit_values, *finite_required] if finite_required else fit_values
396
- domain = _pad_domain(inputs, ref)
399
+ domain = _pad_domain(inputs, ref, symmetric=symmetric)
397
400
  return _clamp_domain(domain, ref, max_domain) if max_domain is not None else domain
398
401
 
399
402
 
@@ -453,6 +456,7 @@ class _ForestState:
453
456
  value: list[float | None]
454
457
  low: list[float | None]
455
458
  high: list[float | None]
459
+ clipped: frozenset[Any]
456
460
  annotations: PreparedAnnotations
457
461
 
458
462
 
@@ -475,6 +479,16 @@ class Forest:
475
479
  symmetric
476
480
  When `ylim` is not set, symmetrize the auto-computed domain
477
481
  around `ref` instead of fitting tightly to the data.
482
+ autoscale
483
+ Strategy for the auto-computed domain when `ylim` is not set.
484
+ `"tight"` (default) fits to the exact min/max of the bucket's
485
+ pooled values. `"robust"` fits an IQR/Tukey fence over them
486
+ instead, so a single extreme interval stops flattening every
487
+ other bar; a discounted interval clips against the resulting
488
+ domain edge and continues into a reserved margin as a gradient
489
+ fade rather than being hidden. Colour resolution is unaffected --
490
+ it always reads a row's true values, never the domain they are
491
+ plotted against.
478
492
  width
479
493
  Bar width in pixels.
480
494
  height
@@ -496,6 +510,7 @@ class Forest:
496
510
  scale: Scale = "table"
497
511
  ylim: tuple[float, float] | None = None
498
512
  symmetric: bool = False
513
+ autoscale: Autoscale = "tight"
499
514
  width: int = 220
500
515
  height: int | None = None
501
516
  show_axis: bool = True
@@ -525,16 +540,30 @@ class Forest:
525
540
  )
526
541
 
527
542
  buckets: dict[Any, list[float]] = {}
543
+ required: dict[Any, list[float]] = {}
528
544
  for i in range(len(scan.row_keys)):
529
545
  key = _domain_key(self, scan.row_keys[i], scan.group_keys[i], scan.split_keys[i])
530
546
  inputs = buckets.setdefault(key, [])
531
547
  inputs.extend(_finite([value[i], low[i], high[i]]))
532
548
  if not is_missing(value[i]):
533
- inputs.extend(domain_values(annotations.by_row[i], axis="x"))
549
+ required.setdefault(key, []).extend(domain_values(annotations.by_row[i], axis="x"))
534
550
  domains = {
535
- key: self.ylim or _pad_domain(vals, self.ref, symmetric=self.symmetric)
551
+ key: _bucket_domain(
552
+ vals,
553
+ self.ref,
554
+ override=self.ylim,
555
+ max_domain=None,
556
+ autoscale=self.autoscale,
557
+ required=required.get(key, ()),
558
+ symmetric=self.symmetric,
559
+ )
536
560
  for key, vals in buckets.items()
537
561
  }
562
+ clipped = frozenset(
563
+ key
564
+ for key, vals in buckets.items()
565
+ if any(v < domains[key][0] or v > domains[key][1] for v in vals)
566
+ )
538
567
 
539
568
  def footer_key(row_key: Any, group: Any, split: Any) -> Any:
540
569
  return _domain_key(self, row_key, group, split)
@@ -547,6 +576,7 @@ class Forest:
547
576
  low=low,
548
577
  high=high,
549
578
  annotations=annotations,
579
+ clipped=clipped,
550
580
  ),
551
581
  footer_key=footer_key if self.show_axis else None,
552
582
  shared_footer=self.scale in ("table", "split_column"),
@@ -573,6 +603,7 @@ class Forest:
573
603
  theme=ctx.theme,
574
604
  width=self.width,
575
605
  height=_plot_height((state.source,), self.height),
606
+ margin=CLIP_MARGIN if key in state.clipped else 0,
576
607
  annotations=state.annotations.by_row[ctx.index],
577
608
  )
578
609
 
@@ -585,6 +616,7 @@ class Forest:
585
616
  fmt=self.axis_fmt or state.source.fmt,
586
617
  theme=ctx.theme,
587
618
  width=self.width,
619
+ margin=CLIP_MARGIN if ctx.key in state.clipped else 0,
588
620
  )
589
621
 
590
622
 
@@ -1371,6 +1403,7 @@ class CoefTable:
1371
1403
  scale: Scale = "table",
1372
1404
  ylim: tuple[float, float] | None = None,
1373
1405
  symmetric: bool = False,
1406
+ autoscale: Autoscale = "tight",
1374
1407
  width: int = 220,
1375
1408
  height: int | None = None,
1376
1409
  show_axis: bool = True,
@@ -1394,6 +1427,15 @@ class CoefTable:
1394
1427
  symmetric
1395
1428
  When `ylim` is not set, symmetrize the auto-computed domain
1396
1429
  around `ref` instead of fitting tightly to the data.
1430
+ autoscale
1431
+ Strategy for the auto-computed domain when `ylim` is not set.
1432
+ `"tight"` (default) fits to the exact min/max of the bucket's
1433
+ pooled values. `"robust"` fits an IQR/Tukey fence over them
1434
+ instead, so a single extreme interval stops flattening every
1435
+ other bar; a discounted interval clips against the resulting
1436
+ domain edge and continues into a reserved margin as a
1437
+ gradient fade rather than being hidden. Composes with
1438
+ `symmetric`: the fence runs first, then the mirrored padding.
1397
1439
  width
1398
1440
  Bar width in pixels.
1399
1441
  height
@@ -1420,6 +1462,7 @@ class CoefTable:
1420
1462
  scale=scale,
1421
1463
  ylim=ylim,
1422
1464
  symmetric=symmetric,
1465
+ autoscale=autoscale,
1423
1466
  width=width,
1424
1467
  height=height,
1425
1468
  show_axis=show_axis,
@@ -849,6 +849,48 @@ def _esc(text: str) -> str:
849
849
  return html.escape(text, quote=False)
850
850
 
851
851
 
852
+ CLIP_MARGIN = 18
853
+ # Pixel reserve on each side of a forest bar's plotting region when its
854
+ # domain bucket contains clipped values -- the strip a `_fade_cap` bleeds
855
+ # into. Shared with `spec.Forest`, which decides per bucket whether to
856
+ # reserve it at all.
857
+
858
+
859
+ def _fade_id(color: str, flip: bool) -> str:
860
+ """Return a deterministic id for a `_fade_cap` gradient.
861
+
862
+ Same collision reasoning as `_hard_clip_id`: SVG ids share one
863
+ namespace across an embedding page, so the id is built from the
864
+ gradient's full content -- its colour and direction. Two calls share
865
+ an id exactly when their gradients are identical, which makes any
866
+ collision harmless.
867
+ """
868
+ safe = "".join(c if c.isalnum() else "_" for c in color)
869
+ return f"fade{'l' if flip else 'r'}_{safe}"
870
+
871
+
872
+ def _fade_cap(
873
+ x: float, top: float, fade_width: float, bar_height: int, color: str, *, flip: bool
874
+ ) -> str:
875
+ """Continue a clipped bar into the reserved margin as a gradient fade.
876
+
877
+ The rectangle starts at the domain edge the bar clipped against and
878
+ fades from the bar's own fill to transparent across `fade_width` px,
879
+ reading as "extends beyond the axis" rather than ending abruptly.
880
+ `flip=True` fades leftward for a low-edge clip.
881
+ """
882
+ gid = _fade_id(color, flip)
883
+ x_attrs = 'x1="1" y1="0" x2="0" y2="0"' if flip else 'x1="0" y1="0" x2="1" y2="0"'
884
+ return (
885
+ f'<defs><linearGradient id="{gid}" {x_attrs}>'
886
+ f'<stop offset="0" stop-color="{color}" stop-opacity="0.75"/>'
887
+ f'<stop offset="1" stop-color="{color}" stop-opacity="0"/>'
888
+ f"</linearGradient></defs>"
889
+ f'<rect x="{x:.2f}" y="{top:.2f}" width="{fade_width:.2f}" '
890
+ f'height="{bar_height}" fill="url(#{gid})"/>'
891
+ )
892
+
893
+
852
894
  def forest_bar(
853
895
  estimate: float | None,
854
896
  lower: float | None,
@@ -862,14 +904,18 @@ def forest_bar(
862
904
  height: int = 18,
863
905
  bar_height: int = 9,
864
906
  inset: int = 3,
907
+ margin: int = 0,
865
908
  annotations: Sequence[ResolvedAnnotation] = (),
866
909
  ) -> str:
867
910
  """Render one interval as an inline SVG bar.
868
911
 
869
912
  The bar spans the interval, a light tick marks the point estimate, and a
870
913
  dashed line marks `ref` when it falls inside `domain`. A bound outside the
871
- domain, including an unbounded one, draws to the edge with a triangular cap
872
- so that clipping is visible rather than silently misleading.
914
+ domain, including an unbounded one, is clipped visibly rather than
915
+ silently: with `margin=0` it draws to the edge with a triangular cap;
916
+ with `margin > 0` the bars plot against the inner
917
+ `[margin, width - margin]` region and a clipped bound continues into
918
+ the margin as a gradient fade to transparent (see `_fade_cap`).
873
919
 
874
920
  Parameters
875
921
  ----------
@@ -889,6 +935,10 @@ def forest_bar(
889
935
  Resolved x-axis rules and bands rendered around the existing bar.
890
936
  width, height, bar_height, inset
891
937
  Geometry in pixels.
938
+ margin
939
+ Pixel reserve on each side of the plotting region for the fade
940
+ treatment of clipped bounds. `0` (the default) keeps the full
941
+ width for bars and marks clipping with triangular caps instead.
892
942
 
893
943
  Returns
894
944
  -------
@@ -896,7 +946,12 @@ def forest_bar(
896
946
  A complete ``<svg>`` element.
897
947
  """
898
948
  low, high = domain
899
- project = _projector(domain, width, inset)
949
+ plot_width = width - 2 * margin
950
+ inner_project = _projector(domain, plot_width, inset)
951
+
952
+ def project(v: float) -> float:
953
+ return margin + inner_project(v)
954
+
900
955
  low_value = low if lower is None or is_missing(lower) else lower
901
956
  high_value = high if upper is None or is_missing(upper) else upper
902
957
  clipped_low = is_missing(lower) or low_value < low
@@ -928,19 +983,26 @@ def forest_bar(
928
983
  f'y2="{top + bar_height:.2f}" stroke="{theme.surface}" stroke-width="1.5"/>'
929
984
  )
930
985
 
931
- cap = bar_height * 0.6
932
- if clipped_high:
933
- tip = width - inset / 2
934
- parts.append(
935
- f'<polygon points="{tip:.2f},{middle:.2f} {tip - cap:.2f},{middle - cap:.2f} '
936
- f'{tip - cap:.2f},{middle + cap:.2f}" fill="{color}"/>'
937
- )
938
- if clipped_low:
939
- tip = inset / 2
940
- parts.append(
941
- f'<polygon points="{tip:.2f},{middle:.2f} {tip + cap:.2f},{middle - cap:.2f} '
942
- f'{tip + cap:.2f},{middle + cap:.2f}" fill="{color}"/>'
943
- )
986
+ if margin:
987
+ fade_width = float(margin - inset)
988
+ if clipped_high:
989
+ parts.append(_fade_cap(x1, top, fade_width, bar_height, color, flip=False))
990
+ if clipped_low:
991
+ parts.append(_fade_cap(x0 - fade_width, top, fade_width, bar_height, color, flip=True))
992
+ else:
993
+ cap = bar_height * 0.6
994
+ if clipped_high:
995
+ tip = width - inset / 2
996
+ parts.append(
997
+ f'<polygon points="{tip:.2f},{middle:.2f} {tip - cap:.2f},{middle - cap:.2f} '
998
+ f'{tip - cap:.2f},{middle + cap:.2f}" fill="{color}"/>'
999
+ )
1000
+ if clipped_low:
1001
+ tip = inset / 2
1002
+ parts.append(
1003
+ f'<polygon points="{tip:.2f},{middle:.2f} {tip + cap:.2f},{middle - cap:.2f} '
1004
+ f'{tip + cap:.2f},{middle + cap:.2f}" fill="{color}"/>'
1005
+ )
944
1006
  if not annotations:
945
1007
  return _svg(width, height, "".join(parts))
946
1008
  area = _PlotArea(
@@ -948,8 +1010,8 @@ def forest_bar(
948
1010
  y_domain=None,
949
1011
  project_x=project,
950
1012
  project_y=None,
951
- left=inset,
952
- right=width - inset,
1013
+ left=margin + inset,
1014
+ right=width - margin - inset,
953
1015
  top=0,
954
1016
  bottom=height,
955
1017
  )
@@ -1078,6 +1140,7 @@ def forest_axis(
1078
1140
  width: int = 220,
1079
1141
  height: int = 22,
1080
1142
  inset: int = 3,
1143
+ margin: int = 0,
1081
1144
  target_ticks: int = 4,
1082
1145
  ) -> str:
1083
1146
  """Render the shared x-axis for a set of forest bars.
@@ -1094,6 +1157,10 @@ def forest_axis(
1094
1157
  Supplies the axis colour and label size.
1095
1158
  width, height, inset
1096
1159
  Geometry in pixels.
1160
+ margin
1161
+ Pixel reserve on each side matching the bars' own `margin`; the
1162
+ axis spine and projection span only the inner region so ticks
1163
+ stay aligned with the bars.
1097
1164
  target_ticks
1098
1165
  Approximate number of ticks wanted.
1099
1166
 
@@ -1103,10 +1170,15 @@ def forest_axis(
1103
1170
  A complete ``<svg>`` element.
1104
1171
  """
1105
1172
  low, high = domain
1106
- project = _projector(domain, width, inset)
1173
+ inner_project = _projector(domain, width - 2 * margin, inset)
1174
+
1175
+ def project(v: float) -> float:
1176
+ return margin + inner_project(v)
1177
+
1107
1178
  baseline = 4.0
1108
1179
  parts = [
1109
- f'<line x1="{inset}" y1="{baseline:.2f}" x2="{width - inset}" y2="{baseline:.2f}" '
1180
+ f'<line x1="{margin + inset}" y1="{baseline:.2f}" '
1181
+ f'x2="{width - margin - inset}" y2="{baseline:.2f}" '
1110
1182
  f'stroke="{theme.axis}" stroke-width="0.75"/>'
1111
1183
  ]
1112
1184
  if low <= ref <= high:
@@ -562,6 +562,94 @@ def test_explicit_ylim_wins_over_symmetric():
562
562
  assert out.axis_rows
563
563
 
564
564
 
565
+ OUTLIER_RAW = {
566
+ "area": ["Core"] * 4,
567
+ "metric": ["A", "B", "C", "Rare"],
568
+ "variant": ["B", "B", "B", "B"],
569
+ "rel": [0.5, -0.2, 0.1, 300.0],
570
+ "rel_lb": [0.3, -0.5, -0.1, 220.0],
571
+ "rel_ub": [0.7, 0.1, 0.3, 410.0],
572
+ }
573
+
574
+
575
+ def test_forest_autoscale_robust_discounts_outlier_and_flags_bucket_clipped():
576
+ table = base(pl.DataFrame(OUTLIER_RAW)).forest("Plot", of="Lift %", autoscale="robust")
577
+ state = _forest_state(table)
578
+ (domain,) = state.domains.values()
579
+ assert domain[1] < 220.0
580
+ assert state.clipped == set(state.domains)
581
+
582
+
583
+ def test_forest_autoscale_tight_default_fits_outlier_with_no_clip():
584
+ table = base(pl.DataFrame(OUTLIER_RAW)).forest("Plot", of="Lift %")
585
+ state = _forest_state(table)
586
+ (domain,) = state.domains.values()
587
+ assert domain[1] >= 410.0
588
+ assert state.clipped == frozenset()
589
+
590
+
591
+ def test_forest_autoscale_robust_composes_with_symmetric():
592
+ table = base(pl.DataFrame(OUTLIER_RAW)).forest(
593
+ "Plot", of="Lift %", ref=0.0, autoscale="robust", symmetric=True
594
+ )
595
+ state = _forest_state(table)
596
+ (domain,) = state.domains.values()
597
+ assert domain[0] == -domain[1]
598
+ assert domain[1] < 220.0
599
+
600
+
601
+ def test_forest_explicit_ylim_wins_over_robust_and_still_flags_clipping():
602
+ table = base(pl.DataFrame(OUTLIER_RAW)).forest(
603
+ "Plot", of="Lift %", autoscale="robust", ylim=(-1.0, 1.0)
604
+ )
605
+ state = _forest_state(table)
606
+ (domain,) = state.domains.values()
607
+ assert domain == (-1.0, 1.0)
608
+ assert state.clipped == set(state.domains)
609
+
610
+
611
+ def test_forest_clipped_bucket_renders_margin_fade_in_html():
612
+ table = base(pl.DataFrame(OUTLIER_RAW)).forest("Plot", of="Lift %", autoscale="robust")
613
+ html = table.gt()._repr_html_()
614
+ assert "linearGradient" in html
615
+
616
+
617
+ def test_forest_unclipped_table_renders_no_fade_in_html():
618
+ table = base(pl.DataFrame(RAW)).forest("Plot", of="Lift %")
619
+ html = table.gt()._repr_html_()
620
+ assert "linearGradient" not in html
621
+
622
+
623
+ def test_forest_robust_domain_retains_annotation_outside_inlier_range():
624
+ # Under autoscale="robust" a forest annotation coordinate rides the
625
+ # `required` join past the IQR fence: it expands the domain even though a
626
+ # genuine data outlier in the same bucket is fenced out. The two live
627
+ # bounds pin both halves -- the annotation (2.0) is retained while the
628
+ # data outlier (6.0) is discounted, so the domain lands near 2.0, not 6.0.
629
+ raw = pl.DataFrame(
630
+ {
631
+ "metric": ["A", "B", "C", "D", "E"],
632
+ "est": [0.30, 0.32, 0.28, 0.31, 5.00],
633
+ "low": [0.20, 0.22, 0.18, 0.21, 4.00],
634
+ "high": [0.40, 0.42, 0.38, 0.41, 6.00],
635
+ }
636
+ )
637
+ table = (
638
+ CoefTable(raw, rows="metric")
639
+ .estimate("Effect", "est", ci=("low", "high"))
640
+ .forest(
641
+ "Plot",
642
+ of="Effect",
643
+ ref=0.0,
644
+ autoscale="robust",
645
+ annotations=(Rule(2.0, axis="x"),),
646
+ )
647
+ )
648
+ _, high = _forest_state(table).domains[("table",)]
649
+ assert high >= 2.0 # annotation retained past the fence
650
+ assert high < 4.0 # data outlier fenced out, not driving the domain
651
+
652
+
565
653
  def test_plot_height_picks_stacked_layout_default():
566
654
  estimate = Estimate("Lift %", "rel", ci=("lb", "ub"))
567
655
  forest = Forest("Plot", of="Lift %")
@@ -124,6 +124,42 @@ def test_forest_bar_without_clipping_has_no_cap():
124
124
  assert "<polygon" not in svg
125
125
 
126
126
 
127
+ def test_forest_bar_margin_fades_clipped_upper_bound_instead_of_cap():
128
+ svg = forest_bar(
129
+ 1.0, 0.5, 99.0, domain=(0.0, 2.0), ref=0.0, color="#000", theme=DEFAULT, margin=18
130
+ )
131
+ assert "linearGradient" in svg
132
+ assert "<polygon" not in svg
133
+
134
+
135
+ def test_forest_bar_margin_fades_clipped_lower_bound_with_flipped_gradient():
136
+ svg = forest_bar(
137
+ 1.0, -99.0, 1.5, domain=(0.0, 2.0), ref=0.0, color="#000", theme=DEFAULT, margin=18
138
+ )
139
+ assert 'id="fadel_' in svg
140
+
141
+
142
+ def test_forest_bar_margin_without_clipping_draws_no_fade():
143
+ svg = forest_bar(
144
+ 1.0, 0.5, 1.5, domain=(0.0, 2.0), ref=0.0, color="#000", theme=DEFAULT, margin=18
145
+ )
146
+ assert "linearGradient" not in svg
147
+ assert "<polygon" not in svg
148
+
149
+
150
+ def test_forest_bar_margin_offsets_projection_into_inner_region():
151
+ # A bar spanning the whole domain starts at margin + inset, not inset.
152
+ svg = forest_bar(
153
+ 1.0, 0.0, 2.0, domain=(0.0, 2.0), ref=0.0, color="#000", theme=DEFAULT, margin=18
154
+ )
155
+ assert 'x="21.00"' in svg
156
+
157
+
158
+ def test_forest_axis_margin_spans_inner_region():
159
+ axis = forest_axis(domain=(0.0, 2.0), ref=0.0, fmt=Number(), theme=DEFAULT, margin=18)
160
+ assert 'x1="21"' in axis
161
+
162
+
127
163
  def test_forest_bar_survives_zero_width_domain():
128
164
  svg = forest_bar(1.0, 1.0, 1.0, domain=(1.0, 1.0), ref=0.0, color="#000", theme=DEFAULT)
129
165
  assert svg.startswith("<svg")
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes