Class: Shadcn::Chart::Plot

Inherits:
Object
  • Object
show all
Defined in:
app/components/shadcn/chart/plot.rb

Overview

The arithmetic a cartesian chart needs, and nothing that draws.

This is the part the pie did not have and the reason the todo said the axis is the work: a bar is a rectangle, but where it goes is a scale, a nice maximum, a set of ticks and a band per category. A plain object, so all of that is asserted directly rather than through a browser — the same trade Calendar::Month makes.

data is a Hash of category to a Hash of series to number, which is what a group(:month, :platform).sum(:visits) becomes after one each_with_object:

{ "January" => { desktop: 186, mobile: 80 },
"February" => { desktop: 305, mobile: 200 } }

Constant Summary collapse

SIZE =
{ width: 560, height: 240 }.freeze
PADDING =

Room for the tick labels on the left and the category labels below.

{ left: 34, right: 8, top: 12, bottom: 24 }.freeze
TICKS =
4
INSET =

The share of a band left empty, so neighbouring categories do not touch.

0.15

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(data: {}, series: nil, stacked: false, width: SIZE[:width], height: SIZE[:height]) ⇒ Plot

Returns a new instance of Plot.



28
29
30
31
32
33
34
# File 'app/components/shadcn/chart/plot.rb', line 28

def initialize(data: {}, series: nil, stacked: false, width: SIZE[:width], height: SIZE[:height])
  @data = data.to_h { |category, values| [ category.to_s, values.to_h.transform_keys(&:to_s) ] }
  @series = (series || @data.values.flat_map(&:keys).uniq).map(&:to_s)
  @stacked = stacked
  @width = width
  @height = height
end

Instance Attribute Details

#dataObject (readonly)

Returns the value of attribute data.



26
27
28
# File 'app/components/shadcn/chart/plot.rb', line 26

def data
  @data
end

#heightObject (readonly)

Returns the value of attribute height.



26
27
28
# File 'app/components/shadcn/chart/plot.rb', line 26

def height
  @height
end

#seriesObject (readonly)

Returns the value of attribute series.



26
27
28
# File 'app/components/shadcn/chart/plot.rb', line 26

def series
  @series
end

#stackedObject (readonly)

Returns the value of attribute stacked.



26
27
28
# File 'app/components/shadcn/chart/plot.rb', line 26

def stacked
  @stacked
end

#widthObject (readonly)

Returns the value of attribute width.



26
27
28
# File 'app/components/shadcn/chart/plot.rb', line 26

def width
  @width
end

Instance Method Details

#bandObject

The horizontal room one category gets, including the space between.



47
# File 'app/components/shadcn/chart/plot.rb', line 47

def band = @band ||= (plot_width / [ categories.size, 1 ].max.to_f)

#barsObject

One rectangle per series per category. Grouped side by side, or stacked one on top of the other — which is the same arithmetic with the bottom moved up by what is already there.



62
63
64
65
66
# File 'app/components/shadcn/chart/plot.rb', line 62

def bars
  data.flat_map.with_index do |(category, values), index|
    stacked ? stacked_bars(category, values, index) : grouped_bars(category, values, index)
  end
end

#baselineObject



53
# File 'app/components/shadcn/chart/plot.rb', line 53

def baseline = y_of(0)

#categoriesObject



36
# File 'app/components/shadcn/chart/plot.rb', line 36

def categories = data.keys

#label_every(pixels_per_character: 7) ⇒ Object

Every label would collide long before a year of days fits, so only every nth is drawn — n from the room the longest label needs against the room a band gives it. Upstream's own charts rotate them instead; a Rails app is likelier to pass twelve months than a thousand points, so this stays a skip rather than a rotation.



93
94
95
96
97
# File 'app/components/shadcn/chart/plot.rb', line 93

def label_every(pixels_per_character: 7)
  longest = categories.map { |category| category.length }.max.to_i

  [ ((longest * pixels_per_character) / band).ceil, 1 ].max
end

#maxObject

The tallest thing the chart has to fit: one bar's value, or a whole stack's, rounded up to something a person would choose as a maximum.



40
41
42
# File 'app/components/shadcn/chart/plot.rb', line 40

def max
  @max ||= nice(data.values.map { |values| tallest(values) }.max.to_f)
end

#plot_heightObject



57
# File 'app/components/shadcn/chart/plot.rb', line 57

def plot_height = height - PADDING[:top] - PADDING[:bottom]

#plot_widthObject



55
# File 'app/components/shadcn/chart/plot.rb', line 55

def plot_width = width - PADDING[:left] - PADDING[:right]

#point_x(index) ⇒ Object

Where a series' line goes. Not the middle of a band: a line's axis is a point scale, so the first reading sits on the left edge and the last on the right, and upstream's own line and area charts touch both. A bar cannot — it needs a band to be wide in — which is why the two shapes place their category labels differently.



73
74
75
76
77
# File 'app/components/shadcn/chart/plot.rb', line 73

def point_x(index)
  return x_of(index) + band / 2 if categories.size < 2

  PADDING[:left] + plot_width * index / (categories.size - 1).to_f
end

#points(key) ⇒ Object



79
80
81
82
83
84
85
86
# File 'app/components/shadcn/chart/plot.rb', line 79

def points(key)
  key = key.to_s

  data.map.with_index do |(category, values), index|
    { category:, value: values[key],
      x: point_x(index).round(2), y: y_of(values[key]).round(2) }
  end
end

#ticksObject



44
# File 'app/components/shadcn/chart/plot.rb', line 44

def ticks = @ticks ||= (0..TICKS).map { |step| max * step / TICKS }

#x_of(index) ⇒ Object



49
# File 'app/components/shadcn/chart/plot.rb', line 49

def x_of(index) = PADDING[:left] + band * index

#y_of(value) ⇒ Object



51
# File 'app/components/shadcn/chart/plot.rb', line 51

def y_of(value) = PADDING[:top] + (plot_height - plot_height * value.to_f / max)