Skip to content

Give custom types control over plot machinery via type_hints - #637

Merged
grantmcdermott merged 20 commits into
mainfrom
consolidate-type-flags
Jul 30, 2026
Merged

Give custom types control over plot machinery via type_hints#637
grantmcdermott merged 20 commits into
mainfrom
consolidate-type-flags

Conversation

@grantmcdermott

@grantmcdermott grantmcdermott commented Jun 19, 2026

Copy link
Copy Markdown
Owner

NOTE: Edited to reflect updated content.

Fixes #543
Fixes #661 (incidentally)
Towards #660

Moves type == "..." branching/exceptions out of the main code files (tinyplot.R, facet.R, legend.R, lim.R) and into dedicated flags that the individual type constructors can access and declare themselves. The core idea is that any bespoke behaviour should be controllable from the type constuctors themselves, rather than relying on brittle, hard-coded logic in the main pipeline. We had already done this for most types, but there were (are) some vestigial cases left in the code; mostly related to "tricky" types like spineplots and ridge plots.

The main mechanism is a new settings$type_hints slot with a set of known flags ("hints") that users can access and set as part of the type's data_*() function. Right now, there are several known hints, which are documented
internally (here), as well as in the updated Types vignette.

Note: type_hints could be considered a sister slot to type_info. They do similar(ish) things---and are both escape hatches for custom behaviour---but are ultimately targeting separate issues. As I wrote in the Types vignette: "type_hints specifies behaviour you want the plot machinery to respect, while type_info is used for passing bespoke rendering payload from data_<type>() through to draw_<type>() (e.g., coordinates, precomputed breaks, and so on)."

Again, both mechanisms are accessible from the individual type_* constructors, so this effectively makes them available to custom types too. So, this should addess @katrinabrock's core ask to have "the same toolbox that you as maintainers have". Hopefully, the updated Types vignette documentation also makes clear how to use and implement them.

Collateral benefits and bug fixes

While not the original target of this PR, I ended up implementing some other FRs and squashing a few other bugs. The most important of which are:

  • New facet.args = list(axes "outer") top-level argument for suppressing the drawing of redundant facet axes. (Remove redundant x/y-axis in facets #661)
  • Fixed a few bugs/issues listed in the "spineplot wishlist" (Spineplot wishlist #660), including overlapping axis labels for faceted spineplots. (Also addresses similar issues for ridge plots).

Out of scope / future PRs

  • Provide a way to register convenience strings for custom types (Custom types: Clarify how name impacts behavior, consider alternatives to hardcoded types in tinyplot::tinyplot #543 point 4), e.g. tinytype_register() mirroring tinytheme_register().
  • There is some residual hard-coded logic in tinyplot.formula() (tinyplot.R L1847-49) for density/histogram/barplot labeling that I haven't addressed here. It's harder to migrate because it runs before sanitize_type() normalises the type input, so it has to handle both the convenience string and the constructor object, and no type_hints exist yet for it to read.
  • Currently, our new facet.args(axes = "outer") argument works in tandem with the existing frame.plot = FALSE logic; i.e., they both suppress the inner facet axes. But we should arguably drop the old behaviour as less principled; would be a breaking change, though.
  • For faceted spineplots, there's still some residual inter-facet spacing slack, due to the RHS axes, which would require a (significantly) larger refactor of our facet window logic.

Replace six scattered hardcoded `type == "spineplot"` checks with semantic
flags the type declares for itself. data_spineplot() now writes a
type_axes_hints list (self_axes, rhs_axis, proportional_lim,
legend_fill_from_col, draw_empty_facet) to settings via the same env2env
write-back pattern used by group_offsets. The main pipeline reads these
flags instead of matching the type string.

Sites converted:
- R/lim.R: proportional_lim (suppress axis-break range expansion)
- R/tinyplot.R: self_axes (dynmar tick-row margin, #635) + draw_empty_facet
- R/facet.R: rhs_axis (omar[4] RHS-axis margin, x2; drops the FIXMEs)
- R/legend.R: rhs_axis (outer-legend bump) + legend_fill_from_col (pt.bg)
- R/legend_multi.R: thread type_axes_hints through the multi-legend path

type_axes_hints is threaded through the exported draw_legend() (new NULL
default + @param) -> build_legend_env() -> stored on legend_env. Pure
refactor: all five flags are TRUE for spineplot, NULL elsewhere, so every
site evaluates identically. Verified byte-identical renders across simple,
flip, facet, by-group, outer-legend, and xlab/ylab=NA (#635) cases. Adds a
spineplot_legend_outer_right snapshot to cover the RHS legend-margin path.
The default legend position is already "right!" (outer), so the existing
spineplot_facet_by test exercises the outer_side + rhs_axis legend-margin
branch. A dedicated outer-right test added no new coverage.
@vincentarelbundock

Copy link
Copy Markdown
Collaborator

Overall, I think this is a very good path to go on. I agree we should remove the type specific logic from the main functions and put as much as we can in the type-specific functions.

The one thing that worries me a bit is not specific to the new flags you just adopted, but relates to our naming conventions. I feel that we haven't been super strict in naming variables internally, and that can make things harder to read and maintain than they should be. For example, group_offsets vs offsets_axis, where we don't follow a clear pattern, like verb_noun_adjective.

This is of course a different issue, but I wanted to raise it here because this PR will likely lead to the creation of several new internal variables.

@katrinabrock

Copy link
Copy Markdown

@grantmcdermott Let me know if you are looking for any kind of feedback from me.


Also, not related exclusively to this issue, I've recently made public the project I (and @rhirk) have been working on that's making use of tinyplot: https://github.com/livingingroups/trackframe

The custom types are here:

They don't suffer too badly from this issue, but @rhirk had previously built the type_arrows functionality based on l rather than segments (here) and I was a bit surprised and confused by how that name = argument impacted the behavior which prompted me to raise #543 in the first place

Follow-up to the spineplot pilot, addressing three gaps in how types
declare behaviour to the generic pipeline.

Remove the last generic-pipeline read of `type_info`. The grouped
(`y_by`) legend-fill branch in prepare_legend() was guarded by a
hardcoded `type == "spineplot"` check, giving spineplot two parallel
legend-fill mechanisms. It now keys off the `legend_fills_from_col`
hint. The fill resolution itself stays put: `col` is only the resolved
group palette after by_aesthetics(), so it cannot move into
data_spineplot().

Rename `type_axes_hints` to `type_hints`, since several flags
(legend fill, empty-group drawing) are not axes concerns. Flags are
renamed to property-style predicates for a consistent convention:

  self_axes            -> draws_own_axes
  rhs_axis             -> has_rhs_axis
  proportional_lim     -> has_proportional_lim
  legend_fill_from_col -> legend_fills_from_col

Replace the empty-plot type list with a data-driven check. Rather than
asking which type this is, ask whether there is anything to draw:

  has_data = length(ix) > 0 || (length(ixmin) > 0 && length(ixmax) > 0)

Requiring both xmin and xmax isolates the types that carry horizontal
geometry there (rect, segments, histogram, spineplot) from those needing
`x` alongside a y-extent (errorbar, pointrange, ribbon). This drops the
`draw_empty_facet` flag and the hardcoded list together.

That last change also fixes a latent bug: the old list drew a group
whenever the type matched, even with nothing to draw. On grouped and
faceted spineplots this redrew each facet's axes once per empty group
(40 spine_axis() calls where 10 suffice), overplotting the self-drawn
labels so they rendered too heavy. Two snapshots are updated to the
corrected single-draw output.
Faceted plots suppress interior axes when frameless, since without a
frame those axes float into the neighbouring panel and collide with its
labels. The generic pipeline already did this via oxaxis/oyaxis, but
three paths bypassed it.

Introduce a single predicate, draw_facet_axis(), as the one place that
answers "should this facet draw this axis?", and route every decision
site through it. Previously the rule was inlined in facet.R and the
self-drawing types reimplemented (or omitted) it.

- spineplot drew its category axis in every facet. It sets axes = FALSE
  in its data fn, so the generic block is skipped entirely and all axis
  drawing happens in draw_spineplot(). Note the gate reads frame.plot
  from type_info, since data_spineplot() forces the settings copy FALSE
  while preserving the user's value.
- ridge likewise drew its y-axis category labels unconditionally, plus
  the "ridge2" x-axis line. draw_ridge() gains named ifacet and
  facet_window_args params; they were already passed via `...`.
- flipped plots gated args_x on oxaxis and args_y on oyaxis, i.e. by
  argument name rather than by resolved side. Since flip swaps
  xside/yside, a flipped frameless facet grid both duplicated the
  category axis and omitted every interior x-axis -- so panels were read
  against a neighbour's scale. Gating on the actual side fixes both.

Free facets keep per-facet axes, as each panel has its own scale.

Also adds the `axes` facet argument from #661: "all", "outer", or
"none". This was previously only reachable as a side effect of
frame.plot = FALSE, so `facet.args = list(axes = "outer")` now drops
interior axes while keeping frames. Unset preserves existing behaviour.
An explicit "none" overrides the free-facet exemption.

Verified byte-identical renders for framed, free, single-facet,
facet_grid, nrow=1 and flipped-framed cases; only the frameless cases
change.
Faceted spineplots overlapped their category labels with the
neighbouring panel under framed themes (e.g. tinytheme("clean")).

The tick-label width is measured once and added to both omar and fmar,
but the nmar/noma split then hands it to the *outer* margin:

  omar[2] = 6.37   (base + ~2.85 label width)
  noma[2] = 4.77   <- label space, figure left edge only
  nmar[2] = 1.6    <- every facet's own left gap

That is right when only the leftmost facet draws a y axis. Under a framed
theme every facet draws one, into 1.6 lines when the labels need 2.85, so
they spill leftwards into the neighbour. Keep the fmar bump (rather than
releasing it to the outer margin) whenever an interior facet will draw
that axis, decided by the same draw_facet_axis() predicate that governs
the drawing itself, so margin and axis cannot disagree.

Two supporting details:

- data_spineplot() forces frame.plot = FALSE so the pipeline skips the
  box that draw_spineplot() draws itself. draw_facet_window() therefore
  saw FALSE under *both* framed and frameless themes and subtracted the
  label width in both cases. Surface the user's real choice via a new
  `framed` hint and key the margin logic off that.
- The subtraction condition keeps its original frameless/free guards, so
  this can only retain space that was previously released, never release
  more.

Costs some panel width, since the labels now occupy each facet's margin
rather than one shared outer margin. Verified byte-identical renders for
points, boxplot, ridge and frameless spineplot; only framed faceted
spineplots change.
The pt.bg block in build_legend_args() still matched two types by name,
sitting immediately beside the already-migrated legend_fills_from_col
check. Convert both so the generic legend code no longer needs to know
which types exist.

Splitting them out revealed that legend_fills_from_col was conflating two
independent behaviours: deriving the swatch fill from `col`, and drawing
the swatch border in par("fg"). Spineplot wants both -- its tiles abut
with no gap, so a foreground border reads better -- but hexbin wants only
the fill. Hence three hints rather than two:

  legend_fills_from_col          spineplot, hexbin
  legend_fills_from_seq_palette  ridge (a lighter step of the ramp)
  legend_border_fg               spineplot

The border check now runs independently of the fill branch, which is what
lets hexbin reuse the fill behaviour without inheriting the border.

Pure refactor: verified byte-identical legend renders across spineplot
(y_by, y_by + lighten, by, plain), ridge (by, gradient, plain), hexbin
(discrete, count) and area-type controls (barplot, boxplot, rect). Also
confirmed by trace that each type declares exactly the expected hints and
that non-migrated types declare none.
The "which labels will the y axis draw?" cascade was duplicated three
times: once in tinyplot.default()'s dynmar block and twice in
draw_facet_window() (faceted and non-faceted branches). Each copy carried
its own `type == "ridge"` and `type == "boxplot" && flip` checks, making
them the largest remaining source of hardcoded type strings in the
pipeline.

Extract just the label-selection part as y_axis_labels(), returning NULL
when the axis is numeric so each caller keeps its own fallback.

Deliberately narrow: only the three genuinely identical branches move. The
surrounding whtsbp arithmetic is left alone because the three copies have
diverged in ways that are not obviously intentional -- differing las
source (get_tpar vs par), strwidth cex (.cex_axis vs par("cex.axis")),
guards on the computed bump (is.finite vs > 0 vs none), the
degenerate-range catch (present in two of three), and cex_fct_adj scaling.
Harmonising those would move margins on many snapshots and belongs in its
own change; see SCRATCH/facet-margin-slack.md.

The faceted copy also has a free-facet branch that measures every facet
and keeps the widest tick set. That stays inline, now nested under the
NULL fallback so branch precedence is unchanged.

Pure refactor. Verified equivalence of old and new selection over a truth
table including the empty-but-non-NULL ylabs edge case, plus byte-identical
renders across ridge (plain/faceted/ridge theme), flipped and unflipped
boxplot, spineplot, numeric axes, free facets, las = 2 and a degenerate
(zero-range) y axis.
d8b8809 used NULL as y_axis_labels()'s "not a categorical axis" signal,
but `levels(y)` is itself NULL for a ridge plot over a *numeric* y (e.g.
`am ~ mpg`). The caller then fell through and substituted axisTicks(),
measuring ~0.028 figure units of tick-label width where the original
cascade measured max(strwidth(NULL)) = 0, so the y labels were pushed away
from the axis title.

Wrap the result in a one-element list, so an empty label set stays
distinguishable from "this type doesn't put categories on the y axis".

Caught by the ridge_gradient_facet* and spineplot_facet_by_fancy snapshots.
My earlier equivalence check missed it because every ridge case in the
truth table used a factor y; the numeric-y case is now covered there too.
`axes = "t"` (ticks) wrongly dropped interior facet axis labels, e.g.

  tinyplot(Survived ~ Sex | Class, facet = "by", data = ttnc,
           type = type_spineplot(weights = ttnc$Freq), axes = "t", lwd = 0)

draw_facet_axis() keys the "outer facets only" rule off framing, on the
grounds that an unframed interior axis floats free and collides with the
neighbouring panel. But `frame.plot` is only a proxy for that, and
sanitize_axes() derives it as all(c(xaxt, yaxt) %in% c("s", "a")) -- so
"t" reports FALSE despite drawing tick marks that anchor the labels
perfectly well. Only "l" (labels) and "n" (none) are genuinely bare.

Add facet_axes_framed() as the single place that answers "are interior
tick labels anchored?", and route all three framing derivations through it
(the generic pipeline plus the two self-drawing types, which each computed
this independently and so shared the bug).

Verified the reported call now renders byte-identical to 99b34a3, i.e.
before the outer-facet rule existed. Call counts confirm the intended
matrix is preserved: per-facet for framed and for axes="t"; outer-only for
axes="labels" and frameless themes (#660); and facet.args = list(axes =
"outer") still forces outer-only under axes="t". No change to the earlier
regression set (8/8 identical).

The deeper fix is to drop `frame.plot` from this decision in favour of an
explicit "would inner axes float?" flag, but that is a wider behaviour
change; noted in SCRATCH/facet-margin-slack.md.
@grantmcdermott
grantmcdermott marked this pull request as ready for review July 29, 2026 23:47
@grantmcdermott grantmcdermott changed the title Centralize type-specific logic via self-declared flags (re: #543) Give custom types control over plot machinery via type_hints Jul 29, 2026
@grantmcdermott
grantmcdermott requested a review from Copilot July 30, 2026 12:31

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a new settings$type_hints mechanism so plot types (including custom types) can declare semantic “behavior” flags that the generic plotting pipeline (margins, facet axes, limits, legend keys) can respect, removing brittle type == "..." branching from core machinery. It also adds explicit facet control for dropping redundant interior axes via facet.args = list(axes = "outer").

Changes:

  • Added type_hints plumbing through the core pipeline (facets, limits, legends) and updated key types (spineplot/ridge/hexbin) to declare the relevant hints.
  • Implemented facet.args$axes ("all" | "outer" | "none") and centralized axis-drawing logic via draw_facet_axis().
  • Updated documentation (Types vignette, NEWS, Rd files) and refreshed snapshot outputs for the axis/legend behavior changes.

Reviewed changes

Copilot reviewed 12 out of 20 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
vignettes/types.qmd Expands custom type documentation and documents type_hints (with examples + supported hint list).
R/zzz.R Adds type_hints to the internal settings key list.
R/type_spineplot.R Declares spineplot type_hints and aligns self-drawn axes with facet axis rules.
R/type_ridge.R Declares ridge type_hints and gates self-drawn axes per facet axis rules.
R/type_hexbin.R Declares legend swatch behavior via type_hints for hexbin.
R/tinyplot.R Introduces type_hints slot in settings, routes it through facet/legend machinery, and refactors empty-plot detection.
R/lim.R Switches proportional-limit behavior from type == "spineplot" to type_hints$has_proportional_lim.
R/legend.R Switches legend margin/swatch special-cases to type_hints and threads type_hints through legend env.
R/legend_multi.R Threads type_hints into multi-legend preparation/drawing.
R/facet.R Adds facet.args$axes validation and centralizes per-facet axis decisions (draw_facet_axis()) + related margin logic.
NEWS.md Announces type_hints and new facet axes control; notes related bug fixes.
man/type_ridge.Rd Syncs docs/examples with updated ridge roxygen.
man/tinyplot.Rd Documents facet.args$axes behavior.
man/facet.Rd Updates signature to include type_hints.
man/draw_legend.Rd Documents new type_hints argument.
man/build_legend_env.Rd Updates signature to include type_hints.
inst/tinytest/_tinysnapshot/spineplot_facet_by.svg Snapshot refresh for spineplot facet axis changes.
inst/tinytest/_tinysnapshot/spineplot_facet_by_fancy.svg Snapshot refresh for spineplot facet axis changes (fancy theme).
inst/tinytest/_tinysnapshot/ridge_gradient_facet_theme_ridge2.svg Snapshot refresh for ridge facet axis changes.
altdoc/pkgdown.yml Updates site build metadata (altdoc/pandoc versions + last built time).
Files not reviewed (5)
  • man/build_legend_env.Rd: Generated file
  • man/draw_legend.Rd: Generated file
  • man/facet.Rd: Generated file
  • man/tinyplot.Rd: Generated file
  • man/type_ridge.Rd: Generated file

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread R/facet.R Outdated
Comment thread vignettes/types.qmd Outdated
@grantmcdermott
grantmcdermott merged commit 71dc898 into main Jul 30, 2026
4 of 5 checks passed
@grantmcdermott
grantmcdermott deleted the consolidate-type-flags branch July 30, 2026 17:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Remove redundant x/y-axis in facets Custom types: Clarify how name impacts behavior, consider alternatives to hardcoded types in tinyplot::tinyplot

4 participants