JuliaPlots / Plots.jl

Powerful convenience for Julia visualizations and data analysis
https://docs.juliaplots.org
Other
1.84k stars 355 forks source link

[FR] Bar plots should accept named tuples like in R #4880

Open wmstack opened 9 months ago

wmstack commented 9 months ago

One of the things that I liked about R was the fact that you could very swiftly spin up a bar chart by using named tuples with c(A = 1, B = 2, C = 3) etc...

image

This is not possible with Julia's named tuples and this seems like a downgrade. On the one hand, you don't need that c function call, on the other hand, they seem less useful. You can try this out with the following code at https://webr.r-wasm.org/latest/

barplot(c(A = 1, B = 2, C = 3))
avishah3 commented 9 months ago

Hi, I would like to work on this, do you mind assigning it to me? I am new to open-source contributions so any guidance on where to locate the relevant code would be really appreciated. Thanks!

BeastyBlacksmith commented 9 months ago

I think for a start we can add the handling of this to the bar recipe here: https://github.com/JuliaPlots/Plots.jl/blob/fcf2e74258e7eb7dbba36c91182d3e8aa3d7c712/src/recipes.jl#L407-L507

avishah3 commented 9 months ago

I added this segment of code to recipes.jl below. I am trying to test it to see if it works as intended but every time I test through the Julia app it uses the original code instead of the modified one. If anyone could let me know how to test it or if there are any issues with the code I would really appreciate it. Thanks


  # custom bar plot function for named tuples
  @recipe function f(::Type{Val{:bar}}, nt::NamedTuple)
      names = collect(keys(nt))  # Extract names from the named tuple
      values = collect(values(nt))  # Extract values from the named tuple

      # Convert names to a format that can be used for plotting
      plotnames = map(string, names)

      x := plotnames
      y := values
      seriestype := :bar
      # Do I have to call the other bar plot function or would this work?
  end
BeastyBlacksmith commented 9 months ago

Yeah, such a dispatch mechanism would be nice, but unfortunately that won't work here. If you insert a @show x, y, z you will see, how the input gets passed to the recipe function (its likely in y).

But you probably can add a method to _preprocess_barlike or an inner function there to do a similiar thing. Alternatively we just add an if block to the recipe.

avishah3 commented 9 months ago

Okay thank you for the advice. I think this is the correct implementation, but let me know if anything is incorrect. Also, what is the best way of testing the code? When I test, it uses the official JuliaPlots code instead of my modified one. I looked at the JuliaPlots documentation but could not find much on this. I want to ensure that it works fine before creating a pull request.

Here is the updated function in recipes.jl:


  # create a bar plot as a filled step function
  @recipe function f(::Type{Val{:bar}}, x, y, z)  # COV_EXCL_LINE
      # check if 'y' is a named tuple and handle accordingly
      if typeof(y) == NamedTuple
          names = collect(keys(y))
          values = collect(values(y))
          plotnames = map(string, names)
          x = plotnames
          y = values
      else
          println("original called")
      end
      ywiden --> false
      procx, procy, xscale, yscale, _ = _preprocess_barlike(plotattributes, x, y)
      nx, ny = length(procx), length(procy)
      axis = plotattributes[:subplot][isvertical(plotattributes) ? :xaxis : :yaxis]
      cv = map(xi -> discrete_value!(plotattributes, :x, xi)[1], procx)
      procx = if nx == ny
          cv
      elseif nx == ny + 1
          0.5diff(cv) + @view(cv[1:(end - 1)])
      else
          error(
              "bar recipe: x must be same length as y (centers), or one more than y (edges).\n\t\tlength(x)=$(length(x)), length(y)=$(length(y))",
          )
      end

      # compute half-width of bars
      bw = plotattributes[:bar_width]
      hw = if bw === nothing
          0.5_bar_width * if nx > 1
              ignorenan_minimum(filter(x -> x > 0, diff(sort(procx))))
          else
              1
          end
      else
          map(i -> 0.5_cycle(bw, i), eachindex(procx))
      end

      # make fillto a vector... default fills to 0
      if (fillto = plotattributes[:fillrange]) === nothing
          fillto = 0
      end
      if yscale in _logScales && !all(_is_positive, fillto)
          # github.com/JuliaPlots/Plots.jl/issues/4502
          # https://github.com/JuliaPlots/Plots.jl/issues/4774
          T = float(eltype(y))
          min_y = NaNMath.minimum(y)
          base = _logScaleBases[yscale]
          baseline = floor_base(min_y, base)
          if min_y == baseline
              baseline /= base
          end
          fillto = map(x -> _is_positive(x) ? T(x) : T(baseline), fillto)
      end

      xseg, yseg = map(_ -> Segments(), 1:2)
      valid_i = isfinite.(procx) .& isfinite.(procy)
      for i in 1:ny
          valid_i[i] || continue
          yi = procy[i]
          center = procx[i]
          hwi = _cycle(hw, i)
          fi = _cycle(fillto, i)
          push!(xseg, center - hwi, center - hwi, center + hwi, center + hwi, center - hwi)
          push!(yseg, yi, fi, fi, yi, yi)
      end

      # widen limits out a bit
      expand_extrema!(axis, scale_lims(ignorenan_extrema(xseg.pts)..., default_widen_factor))

      # switch back
      if !isvertical(plotattributes)
          xseg, yseg = yseg, xseg
          x, y = y, x
      end

      # reset orientation
      orientation := default(:orientation)

      # draw the bar shapes
      @series begin
          seriestype := :shape
          series_annotations := nothing
          primary := true
          x := xseg.pts
          y := yseg.pts
          # expand attributes to match indices in new series data
          for k in _segmenting_vector_attributes ∪ _segmenting_array_attributes
              if (v = get(plotattributes, k, nothing)) isa AVec
                  if eachindex(v) != eachindex(y)
                      @warn "Indices $(eachindex(v)) of attribute `$k` do not match data indices $(eachindex(y))."
                  end
                  # Each segment is 6 elements long, including the NaN separator.
                  # One segment is created for each non-NaN element of `procy`.
                  # There is no trailing NaN, so the last repetition is dropped.
                  plotattributes[k] = @views repeat(v[valid_i]; inner = 6)[1:(end - 1)]
              end
          end
          ()
      end

      # add empty series
      primary := false
      seriestype := :scatter
      markersize := 0
      markeralpha := 0
      fillrange := nothing
      x := procx
      y := procy
      ()
  end
BeastyBlacksmith commented 9 months ago

To test that code you run:

using Pkg
Pkg.activate(temp=true)
Pkg.develop("Plots")

then you change the code in ~/.julia/dev/Plots and run

Pkg.activate(joinpath(homedir(), ".julia", "dev", "Plots"))
using Plots

and then your code should get used.