Repository: MakieOrg/AlgebraOfGraphics.jl
Pull request #591: allow combining `histogram` with `visual(Stairs)`
Author: login: simeonschaub
Created: 2025-01-21T10:48:37Z | Merged: 2025-03-25T19:18:05Z

and other plot types such as `Scatter`. Possibly an alternative to #572.

The only drawback here is that the `visual(...)` needs to come before
`histogram()`, otherwise an error is thrown because `width` is not
mapped for other plot types. What do you think of this approach?



Diff:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c43c8e27dd..67b3890b64 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,8 @@
 
 ## Unreleased
 
+- Added `plottype` argument to `histogram` to allow for different plot types [#591](https://github.com/MakieOrg/AlgebraOfGraphics.jl/pull/591).
+
 ## v0.9.5 - 2025-03-14
 
 - Added `mergeable(layer.plottype, layer.primary)` function, intended for extension by third-party packages that define recipes [#592](https://github.com/MakieOrg/AlgebraOfGraphics.jl/pull/592).
@@ -121,7 +123,7 @@
 
 ## v0.6.11 - 2022-08-08
 
-- Added `paginate` for pagination of large facet plots. 
+- Added `paginate` for pagination of large facet plots.
 
 ## v0.6.8 - 2022-06-14
 
diff --git a/docs/src/examples/statistical-analyses/histograms.md b/docs/src/examples/statistical-analyses/histograms.md
index ca936a483f..6b76523b6c 100644
--- a/docs/src/examples/statistical-analyses/histograms.md
+++ b/docs/src/examples/statistical-analyses/histograms.md
@@ -15,6 +15,12 @@ plt = data(df) * mapping(:x, color=:c, stack=:c) * histogram(bins=20)
 fg = draw(plt)
 ````
 
+````@example histograms
+df = (x=randn(1000), c=rand(["a", "b"], 1000))
+plt = data(df) * mapping(:x, color=:c, linestyle=:c) * histogram(Stairs; bins=20)
+fg = draw(plt)
+````
+
 ````@example histograms
 df = (x=rand(1000), y=randn(1000))
 plt = data(df) * mapping(:x, :y) * histogram(bins=20)
diff --git a/src/transformations/histogram.jl b/src/transformations/histogram.jl
index a7fff0f697..0c77f1e0a8 100644
--- a/src/transformations/histogram.jl
+++ b/src/transformations/histogram.jl
@@ -34,42 +34,68 @@ function _histogram(vs::Tuple; bins=sturges(length(vs[1])), weights=automatic,
     return normalize(h, mode=normalization)
 end
 
-Base.@kwdef struct HistogramAnalysis{D, B}
-    datalimits::D=automatic
-    bins::B=automatic
-    closed::Symbol=:left
-    normalization::Symbol=:none
+struct HistogramAnalysis{plottype <: Plot, D, B}
+    datalimits::D
+    bins::B
+    closed::Symbol
+    normalization::Symbol
 end
 
-function (h::HistogramAnalysis)(input::ProcessedLayer)
+function HistogramAnalysis{plottype}(;
+        datalimits::D=automatic,
+        bins::B=automatic,
+        closed::Symbol=:left,
+        normalization::Symbol=:none,
+    ) where {plottype <: Plot, D, B}
+    return HistogramAnalysis{plottype, D, B}(datalimits, bins, closed, normalization)
+end
+HistogramAnalysis(; options...) = HistogramAnalysis{Plot{plot}}(; options...)
+
+histogram_preprocess_named(::Type{<:Plot}, edges, weights) = (;)
+histogram_preprocess_named(::Type{BarPlot}, edges, weights) = (; width=diff(first(edges)))
+histogram_preprocess_positional(::Type{<:Plot}, edges, weights) = (map(midpoints, edges)..., weights)
+function histogram_preprocess_positional(::Type{Stairs}, edges, weights)
+    edges = only(edges)
+    phantomedge = edges[end] # to bring step back to baseline
+    edges = vcat(edges, phantomedge)
+    z = zero(eltype(weights))
+    heights = vcat(z, weights, z)
+    return (edges, heights)
+end
+histogram_default_attributes(::Type{<:Plot}) = NamedArguments()
+histogram_default_attributes(::Type{BarPlot}) = NamedArguments((; :gap => 0, :dodge_gap => 0))
+
+function (h::HistogramAnalysis{_plottype})(input::ProcessedLayer) where {_plottype}
     datalimits = h.datalimits === automatic ? defaultdatalimits(input.positional) : h.datalimits
     options = valid_options(; datalimits, h.bins, h.closed, h.normalization)
 
+    N = length(input.positional)
+    default_plottype = categoricalplottypes[N]
+    plottype = Makie.plottype(_plottype, input.plottype, default_plottype)
+
     output = map(input) do p, n
         hist = _histogram(Tuple(p); pairs(n)..., pairs(options)...)
         edges, weights = hist.edges, hist.weights
-        named = length(edges) == 1 ? (; width=diff(first(edges))) : (;)
-        return (map(midpoints, edges)..., weights), named
+        named = histogram_preprocess_named(plottype, edges, weights)
+        positional = histogram_preprocess_positional(plottype, edges, weights)
+        return positional, named
     end
 
-    N = length(input.positional)
     label = h.normalization == :none ? "count" : string(h.normalization)
     labels = set(output.labels, N+1 => label)
-    attributes = if N == 1
-        set(output.attributes, :gap => 0, :dodge_gap => 0)
-    else
-        output.attributes
-    end
-    default_plottype = categoricalplottypes[N]
-    plottype = Makie.plottype(output.plottype, default_plottype)
+    attributes = merge(output.attributes, histogram_default_attributes(plottype))
     return ProcessedLayer(output; plottype, labels, attributes)
 end
 
 """
-    histogram(; bins=automatic, datalimits=automatic, closed=:left, normalization=:none)
+    histogram(plottype::Type{<:Plot} = Plot{plot}; bins=automatic, datalimits=automatic, closed=:left, normalization=:none)
 
 Compute a histogram.
 
+A plot type can be passed as the first argument controlling the type of plot the histogram
+is displayed as, e.g. `histogram(Stairs)` creates a stephist. The default plot type for
+1-dimensional histograms is `BarPlot`, `Heatmap` for 2d, and `Volume` for 3d histograms.
+
 The attribute `bins` can be an `Integer`, an `AbstractVector` (in particular, a range), or
 a `Tuple` of either integers or abstract vectors (useful for 2- or 3-dimensional histograms).
 When `bins` is an `Integer`, it denotes the approximate number of equal-width
@@ -97,5 +123,6 @@ Weighted data is supported via the keyword `weights` (passed to `mapping`).
 
     Normalizations are computed withing groups. For example, in the case of
     `normalization=:pdf`, sum of weights *within each group* will be equal to `1`.
+
 """
-histogram(; options...) = transformation(HistogramAnalysis(; options...))
+histogram(plottype::Type{<:Plot} = Plot{plot}; options...) = transformation(HistogramAnalysis{plottype}(; options...))
diff --git a/test/reference_tests.jl b/test/reference_tests.jl
index 1d3b892263..a8c1b8c8f5 100644
--- a/test/reference_tests.jl
+++ b/test/reference_tests.jl
@@ -27,7 +27,7 @@ reftest("barplot cat color palette") do
         draw(scales(Color = (; palette = :Set1_3)))
 end
 
-reftest("barplot layout") do 
+reftest("barplot layout") do
     data((; x = ["A", "B", "C"], y = 1:3, z = ["X", "Y", "Z"])) * mapping(:x, :y; layout = :z) * visual(BarPlot) |> draw
 end
 
@@ -244,6 +244,18 @@ reftest("histogram stack") do
     draw(specs)
 end
 
+reftest("histogram stairs cat color") do
+    df = (x=[sin.(1:500); sin.(1:500) .* 2], z=repeat(["a", "b"], inner = 500))
+    specs = data(df) * mapping(:x, layout=:z, color = :z) * histogram(Stairs) * visual(alpha = 0.8, linewidth = 4)
+    draw(specs)
+end
+
+reftest("histogram scatter cat color") do
+    df = (x=[sin.(1:500); sin.(1:500) .* 2], z=repeat(["a", "b"], inner = 500))
+    specs = data(df) * mapping(:x, layout=:z, color = :z) * histogram(Scatter) * visual(alpha = 0.8, marker = :cross, markersize = 20)
+    draw(specs)
+end
+
 reftest("histogram 2d") do
     df = (x=sin.(1:300), y=cos.(1:300))
     specs = data(df) * mapping(:x, :y) * histogram()
@@ -736,7 +748,7 @@ function presorted_plot(; with_missing::Bool)
     end
     group = ["2", "3", "1", "1", "3", "2"]
     some_value = sort(exp.(sin.(1:6)))
-    
+
     df = (; countries, group, some_value)
 
     m1 = mapping(:countries, :some_value, color = :group)
@@ -744,7 +756,7 @@ function presorted_plot(; with_missing::Bool)
     m3 = mapping(:countries => presorted, :some_value, color = :group => presorted)
 
     base = data(df) *  visual(BarPlot, direction = :x)
-    
+
     f = Figure()
     fg1 = draw!(f[1, 1], base * m1)
     fg2 = draw!(f[2, 1], base * m2)
@@ -795,7 +807,7 @@ reftest("categorical color from continuous") do
     f
 end
 
-reftest("title subtitle footnotes") do 
+reftest("title subtitle footnotes") do
     spec = pregrouped(
         fill(1:5, 6),
         fill(11:15, 6),
@@ -816,7 +828,7 @@ reftest("title subtitle footnotes") do
     )
 end
 
-reftest("title subtitle footnotes single unconstrained facet") do 
+reftest("title subtitle footnotes single unconstrained facet") do
     spec = data((; x = 1:10, y = 11:20)) * mapping(:x, :y) * visual(Scatter)
     draw(
         spec;
@@ -831,7 +843,7 @@ reftest("title subtitle footnotes single unconstrained facet") do
     )
 end
 
-reftest("title") do 
+reftest("title") do
     spec = pregrouped(
         fill(1:5, 6),
         fill(11:15, 6),
@@ -847,7 +859,7 @@ reftest("title") do
     )
 end
 
-reftest("title subtitle footnotes settings") do 
+reftest("title subtitle footnotes settings") do
     spec = pregrouped(
         fill(1:5, 6),
         fill(11:15, 6),
@@ -877,10 +889,10 @@ reftest("title subtitle footnotes settings") do
         ),
         axis = (; width = 100, height = 100)
     )
-    
+
 end
 
-reftest("title subtitle footnotes fontsize inherit") do 
+reftest("title subtitle footnotes fontsize inherit") do
     spec = pregrouped(
         fill(1:5, 6),
         fill(11:15, 6),
@@ -902,7 +914,7 @@ reftest("title subtitle footnotes fontsize inherit") do
     )
 end
 
-reftest("dodge barplot with errorbars") do 
+reftest("dodge barplot with errorbars") do
     f = Figure()
     df = (
         x = [1, 1, 2, 2],
@@ -960,7 +972,7 @@ reftest("dodge scatter with rangebars") do
         yhigh = cos.(range(0, 2pi, length = 20)) .+ 0.3,
         dodge = repeat(["A", "B"], 10)
     )
-    
+
     f = Figure()
     spec1 = data(df) * (mapping(:x, :y, dodge_x = :dodge, color = :dodge) * visual(Scatter) + mapping(:x, :ylow, :yhigh, dodge_x = :dodge, color = :dodge) * visual(Rangebars))
     spec2 = data(df) * (mapping(:y, :x, dodge_y = :dodge, color = :dodge) * visual(Scatter) + mapping(:x, :ylow, :yhigh, dodge_y = :dodge, color = :dodge) * visual(Rangebars, direction = :x))
@@ -974,10 +986,10 @@ end
 reftest("manual legend labels in visual") do
     df_subjects = (; x = repeat(1:10, 10), y = cos.(1:100), id = repeat(1:10, inner = 10))
     df_func = (; x = range(1, 10, length = 20), y = cos.(range(1, 10, length = 20)))
-    
+
     spec1 = data(df_subjects) * mapping(:x, :y, group = :id => nonnumeric) * visual(Lines, linestyle = :dash, color = (:black, 0.2), label = "Subject data")
     spec2 = data(df_func) * mapping(:x, :y) * (visual(Lines, color = :tomato) + visual(Scatter, markersize = 12, color = :tomato, strokewidth = 2)) * visual(label = L"\cos(x)")
-    
+
     draw(spec1 + spec2)
 end
 
@@ -986,7 +998,7 @@ reftest("manual legend order") do
     spec1 = data(df) * mapping(:x, :y, color = :group) * visual(Lines)
 
     spec2 = data((; x = 1:10, y = cos.(1:10) .+ 2)) * mapping(:x, :y) * visual(Scatter, color = :purple, label = "Scatter")
-    
+
     f = Figure()
     fg = draw!(f[1, 1], spec1 + spec2)
     legend!(f[1, 2], fg)
@@ -1130,7 +1142,7 @@ let
     end
 end
 
-let 
+let
     df = (;
         wres = 1:10,
         age = 11:20,
@@ -1154,7 +1166,7 @@ let
     end
 end
 
-let 
+let
     df = (
         x = repeat(1:10, 36),
         y = cumsum(sin.(range(0, 10pi, length = 360))),
diff --git a/test/reference_tests/histogram scatter cat color ref.png b/test/reference_tests/histogram scatter cat color ref.png
new file mode 100644
index 0000000000..733ac4f9f5
Binary files /dev/null and b/test/reference_tests/histogram scatter cat color ref.png differ
diff --git a/test/reference_tests/histogram stairs cat color ref.png b/test/reference_tests/histogram stairs cat color ref.png
new file mode 100644
index 0000000000..1dbce56969
Binary files /dev/null and b/test/reference_tests/histogram stairs cat color ref.png differ


Reviews:
user: login: jkrumbiegel
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-03-13T11:52:14Z
user: login: jkrumbiegel
body: Ok I've thought about this again and I think `Visual` is not quite right here after all. The reason being that the visual attributes are not necessary to control the computation of the histogram data for the given plottype. For that you only need the type. And there's already the documented way of merging in attributes by appending a `* visual(...)` so we don't need to duplicate that for now by exporting `Visual` which is sort of confusing anyway. Maybe later with multilayer transformations. But for this case, a `plottype` argument is probably the easiest overall. This could also be an optional positional arg. I kind of like `histogram(Stairs)`. What do you think?
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-16T20:36:31Z
user: login: jkrumbiegel
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-25T18:37:29Z

Review comments:
diff_hunk: @@ -39,34 +39,49 @@ Base.@kwdef struct HistogramAnalysis{D, B}
     bins::B=automatic
     closed::Symbol=:left
     normalization::Symbol=:none
+    visual::Union{typeof(automatic), Layer}=automatic
 end
 
 function (h::HistogramAnalysis)(input::ProcessedLayer)
     datalimits = h.datalimits === automatic ? defaultdatalimits(input.positional) : h.datalimits
     options = valid_options(; datalimits, h.bins, h.closed, h.normalization)
 
+    visual = h.visual
+    N = length(input.positional)
+    default_plottype = categoricalplottypes[N]
+    plottype = Makie.plottype(input.plottype, visual === automatic ? default_plottype : (visual.transformation::Visual).plottype)
path: src/transformations/histogram.jl
user: login: jkrumbiegel
body: I think I would expect a `visual` type to always take precedence here?
created_at: 2025-03-13T11:49:21Z
author_association: MEMBER
diff_hunk: @@ -39,34 +39,49 @@ Base.@kwdef struct HistogramAnalysis{D, B}
     bins::B=automatic
     closed::Symbol=:left
     normalization::Symbol=:none
+    visual::Union{typeof(automatic), Layer}=automatic
path: src/transformations/histogram.jl
user: login: jkrumbiegel
body: Maybe it would be better to directly use `AlgebraOfGraphics.Visual` for this case? Maybe we need to export it then
created_at: 2025-03-13T11:51:04Z
author_association: MEMBER
diff_hunk: @@ -39,34 +39,49 @@ Base.@kwdef struct HistogramAnalysis{D, B}
     bins::B=automatic
     closed::Symbol=:left
     normalization::Symbol=:none
+    visual::Union{typeof(automatic), Layer}=automatic
 end
 
 function (h::HistogramAnalysis)(input::ProcessedLayer)
     datalimits = h.datalimits === automatic ? defaultdatalimits(input.positional) : h.datalimits
     options = valid_options(; datalimits, h.bins, h.closed, h.normalization)
 
+    visual = h.visual
+    N = length(input.positional)
+    default_plottype = categoricalplottypes[N]
+    plottype = Makie.plottype(input.plottype, visual === automatic ? default_plottype : (visual.transformation::Visual).plottype)
+
     output = map(input) do p, n
         hist = _histogram(Tuple(p); pairs(n)..., pairs(options)...)
         edges, weights = hist.edges, hist.weights
-        named = length(edges) == 1 ? (; width=diff(first(edges))) : (;)
-        return (map(midpoints, edges)..., weights), named
+        named = plottype == BarPlot ? (; width=diff(first(edges))) : (;)
+        positional = if plottype == Stairs
path: src/transformations/histogram.jl
user: login: jkrumbiegel
body: I think I'd like dispatch better here than `if`
created_at: 2025-03-13T11:51:41Z
author_association: MEMBER

PR comments:
user: login: jkrumbiegel
created_at: 2025-01-30T10:33:07Z
body: I've thought about similar things as well, I'm not sure if `visual` is a good idea here. I think it's a bit convoluted to understand that a transformation might react to a plot type attached to a layer before it is applied, because most transformations come with their own plot types (like histogram does as well). Post-multiplying a `visual` is ok because you're just changing the output of the transformation as expected. But as your code reflects, you can't of course simply reuse the input data for a histogram for a stairs plot and expect that to work.

I can think of two options. One would be to instead multiply with a `visual` that does actually look like a stairs version of a barplot. This would have to be added to Makie.

The other option is to add a keyword to the transformation itself in order to choose the desired plot primitive. Then I would have no problem with modifying the data internally, that's entirely expected. I think in general this will have to be done for some transformations because they can create multiple layers with different visuals. If you have that, post-multiplying with `visual(some_plottype)` will never work. I just haven't come up with a way yet that I would like such an interface to look, because you'd generally want to be able to edit all visual properties of the layers that a transformation creates. I had been thinking of actually just passing a `visual` as a keyword argument. Like `histogram(visual = visual(Stairs, alpha = 0.3))` where there could be multiple different ones for transformations with multiple layers.
author_association: MEMBER
user: login: simeonschaub
created_at: 2025-01-30T11:57:47Z
body: You are right, the more I thought about it the less convinced I was that my initial proposal was the right way to go. I now tried implementing your second suggestion with the `visual` keyword argument, what do you think?
author_association: CONTRIBUTOR
user: login: simeonschaub
created_at: 2025-03-05T16:35:36Z
body: Bump :)
author_association: CONTRIBUTOR
user: login: jkrumbiegel
created_at: 2025-03-13T11:53:54Z
body: This would also need an addition to the docstring of `histogram` and probably a docs example as well.

My only hesitation is that I haven't yet thought about what to do in multi-layer transforms and if those should share an interface with this situation here, but maybe that's premature API optimization, in general passing a `Visual` seems sensible to control exactly what it is, plottype + attributes.
author_association: MEMBER
user: login: simeonschaub
created_at: 2025-03-14T14:21:58Z
body: Thanks for the comments and suggestions! I exported `Visual` for now so we can use it here to simplify the logic, I hope that's fine.

> My only hesitation is that I haven't yet thought about what to do in multi-layer transforms and if those should share an interface with this situation here, but maybe that's premature API optimization, in general passing a Visual seems sensible to control exactly what it is, plottype + attributes

Hmm, yeah, I must admit I haven't thought about the multi-layer case until now, what kind of applications were you thinking of? As you said though, I would probably go with this for now and we can always revisit when it comes up again.
author_association: CONTRIBUTOR
user: login: simeonschaub
created_at: 2025-03-17T10:39:31Z
body: Yes, agree that's better! Implemented the changes
author_association: CONTRIBUTOR
user: login: simeonschaub
created_at: 2025-03-25T15:15:06Z
body: Bump
author_association: CONTRIBUTOR