charma 0.1.0

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.
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'charma'
4
+
5
+ SampleMaker = Struct.new(:size) do
6
+ def r
7
+ n=0.5
8
+ 4.times.inject(0){ |acc,| acc + rand(-n..n) }
9
+ end
10
+
11
+ def samples(*peaks)
12
+ srand(0)
13
+ Array.new(size){
14
+ r + peaks.sample
15
+ }
16
+ end
17
+ end
18
+
19
+ foobar = SampleMaker.new(20000)
20
+
21
+ small = SampleMaker.new(6)
22
+
23
+ Charma::Document.new{ |doc|
24
+ [
25
+ {
26
+ title: "Lorem ipsum",
27
+ series:[
28
+ {
29
+ name: "foo",
30
+ y: Array.new(4){ |n| foobar.samples(n+2, (n+2)*2) }
31
+ },
32
+ {
33
+ name: "bar",
34
+ y: Array.new(4){ |n| foobar.samples(n+2, (n+2)*2, 6) }
35
+ },
36
+ ],
37
+ x_ticks: %w(Q1 Q2 Q3 Q4),
38
+ },
39
+ {
40
+ title: "Small size sample",
41
+ series:[
42
+ {
43
+ name: "foo",
44
+ y: Array.new(4){ |n| small.samples(n+2, (n+2)*2) }
45
+ },
46
+ {
47
+ name: "bar",
48
+ y: Array.new(4){ |n| small.samples(n+2, (n+2)*2, 6) }
49
+ },
50
+ ],
51
+ x_ticks: %w(Q1 Q2 Q3 Q4),
52
+ },
53
+ ].each do |opts|
54
+ doc.new_page do |page|
55
+ case opts
56
+ when Hash
57
+ page.add_violinchart( opts )
58
+ when Array
59
+ opts.each{ |o| page.add_violinchart( o ) }
60
+ else
61
+ raise "unexpected input #{opts.inspect}"
62
+ end
63
+ end
64
+ end
65
+ }.render( File.basename(__FILE__, ".*")+".pdf" )
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charma
4
+ class BarChart < Chart
5
+ def initialize(opts)
6
+ super(opts)
7
+ end
8
+
9
+ def draw_bar(pdf, rect, yrange, ys, cols)
10
+ ratio = 0.75
11
+ _, bars, = rect.hsplit( (1-ratio)/2, ratio, (1-ratio)/2 )
12
+ bar_rects = bars.hsplit(*Array.new(ys.size,1))
13
+ bar_rects.zip(ys, cols) do |bar, y, col|
14
+ ay = abs_y_positoin(y, bar, yrange)
15
+ zero = abs_y_positoin(0, bar, yrange)
16
+ b, t = [ ay, zero ].minmax
17
+ rc = Rect.new( bar.x, t, bar.w, (t-b) )
18
+ fill_rect( pdf, rc, col )
19
+ end
20
+ end
21
+
22
+ def render_chart(pdf, rect, yrange)
23
+ stroke_rect(pdf, rect)
24
+ y_values = values(:y).transpose
25
+ bar_areas = rect.hsplit(*Array.new(y_values.size,1))
26
+ cols = if y_values.first.size==1
27
+ colors(y_values.size).map{ |e| [e] }
28
+ else
29
+ [colors(y_values.first.size)] * y_values.size
30
+ end
31
+ y_values.zip(bar_areas, cols).each do |ys, rc, c|
32
+ draw_bar(pdf, rc, yrange, ys, c)
33
+ end
34
+ end
35
+
36
+ def render_xticks(pdf, area)
37
+ rects = area.hsplit(*Array.new(@opts[:x_ticks].size){ 1 }).map{ |rc0|
38
+ rc0.hsplit(1,8,1)[1]
39
+ }
40
+ draw_samesize_texts( pdf, rects, @opts[:x_ticks], valign: :top )
41
+ end
42
+
43
+ def bar_range
44
+ ymin = [0, values(:y).flatten.min * 1.1].min
45
+ ymax = [0, values(:y).flatten.max * 1.1].max
46
+ [ ymin, ymax ]
47
+ end
48
+
49
+ def render( pdf, rect )
50
+ stroke_rect(pdf, rect)
51
+ title_text = @opts[:title]
52
+ title, main, ticks, bottom = rect.vsplit(
53
+ (title_text ? 1 : 0),
54
+ 7,
55
+ (@opts[:x_ticks] ? 0.5 : 0),
56
+ (bottom_legend? ? 0.5 : 0))
57
+ draw_text( pdf, title, title_text ) if title_text
58
+ hratio = [(@opts[:y_label] ? 1 : 0), 1, 10]
59
+ ylabel, yticks, chart = main.hsplit(*hratio)
60
+ yrange = @opts[:y_range] || bar_range
61
+ render_chart(pdf, chart, yrange)
62
+ if @opts[:y_label]
63
+ render_rottext(pdf, ylabel, @opts[:y_label] )
64
+ end
65
+ if @opts[:x_ticks]
66
+ _, _, xticks = ticks.hsplit(*hratio)
67
+ render_xticks(pdf, xticks)
68
+ end
69
+ yvalues = tick_values(:y, yrange)
70
+ render_yticks(pdf, yticks, yrange, yvalues)
71
+ render_y_grid(pdf, chart, yrange, yvalues)
72
+ render_legend(pdf, bottom) if bottom_legend?
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charma
4
+ class Chart
5
+ def initialize(opts)
6
+ @opts = opts
7
+ end
8
+
9
+ def stroke_rect( pdf, rect )
10
+ pdf.stroke{
11
+ pdf.rectangle( [rect.x, rect.y], rect.w, rect.h )
12
+ }
13
+ end
14
+
15
+ def values(sym)
16
+ @opts[:series].map{ |s|
17
+ s[sym].map(&:to_f)
18
+ }
19
+ end
20
+
21
+ def bottom_legend?
22
+ has_legend?
23
+ end
24
+
25
+ def has_legend?
26
+ @opts[:series].any?{ |s| ! s[:name].nil? }
27
+ end
28
+
29
+ def scale_type(sym)
30
+ key = :"#{sym}_scale"
31
+ case @opts[key]
32
+ when :log10
33
+ :log10
34
+ else
35
+ :linear
36
+ end
37
+ end
38
+
39
+ def scale_value(axis, v)
40
+ case scale_type(axis)
41
+ when :log10
42
+ Math.log10(v)
43
+ else
44
+ v
45
+ end
46
+ end
47
+
48
+ def unscale_value(axis, v)
49
+ case scale_type(axis)
50
+ when :log10
51
+ 10.0**v
52
+ else
53
+ v
54
+ end
55
+ end
56
+
57
+ def abs_x_positoin(v, rc, xrange)
58
+ rx, min, max = [ v, *xrange ].map{ |e| scale_value(:x, e) }
59
+ (rx-min) * rc.w / (max-min) + rc.x
60
+ end
61
+
62
+ def abs_y_positoin(v, rc, yrange)
63
+ ry, min, max = [ v, *yrange ].map{ |e| scale_value(:y, e) }
64
+ (ry-min) * rc.h / (max-min) + rc.bottom
65
+ end
66
+
67
+ def fill_rect( pdf, rect, col )
68
+ pdf.save_graphics_state do
69
+ pdf.fill{
70
+ pdf.fill_color( col )
71
+ pdf.rectangle( [rect.x, rect.y], rect.w, rect.h )
72
+ }
73
+ end
74
+ end
75
+
76
+ def draw_text( pdf, rect, text, opts = {} )
77
+ pdf.text_box( text,
78
+ at:rect.topleft,
79
+ width:rect.w,
80
+ height:rect.h,
81
+ align: (opts[:align] || :center),
82
+ valign: (opts[:valign] || :center),
83
+ size: (opts[:size] || rect.h),
84
+ overflow: :shrink_to_fit )
85
+ end
86
+
87
+ def colors(n)
88
+ case n
89
+ when 0, 1
90
+ return ["666666"]
91
+ else
92
+ f = ->(t0){
93
+ t = t0 % 3
94
+ v = case t
95
+ when 0..1 then t
96
+ when 1..2 then 2-t
97
+ else 0
98
+ end
99
+ "%02x" % (v**0.5*255).round
100
+ }
101
+ Array.new(n){ |i|
102
+ t = i*3.0/n+0.5
103
+ [f[t],f[t+1],f[t+2]].join
104
+ }
105
+ end
106
+ end
107
+
108
+ def draw_samesize_texts( pdf, rects, texts, opts={} )
109
+ pdf.save_graphics_state do
110
+ size = texts.zip(rects).map{ |txt,rc|
111
+ w = pdf.width_of(txt, size:1)
112
+ h = pdf.height_of(txt, size:1)
113
+ [rc.w.to_f/w ,rc.h.to_f/h].min
114
+ }.min
115
+ texts.zip(rects).each do |txt, rc|
116
+ draw_text( pdf, rc, txt, size:size, **opts )
117
+ end
118
+ end
119
+ end
120
+
121
+ def render_rottext( pdf, rect, text )
122
+ pdf.rotate(90, origin: rect.center) do
123
+ rc = rect.rot90
124
+ w = pdf.width_of(text, size:1)
125
+ h = pdf.height_of(text, size:1)
126
+ size = [rc.w.to_f/w ,rc.h.to_f/h].min
127
+ pdf.draw_text( text, size:size, at:rc.bottomleft )
128
+ end
129
+ end
130
+
131
+ def render_legend( pdf, rect )
132
+ names = @opts[:series].map.with_index{ |e,ix| e[:name] || "series #{ix}" }
133
+ rects = rect.hsplit( *([1]*names.size) )
134
+ name_areas, bar_areas = rects.map{ |rc| rc.hsplit(1,1) }.transpose
135
+ draw_samesize_texts( pdf, name_areas, names, align: :right )
136
+ cols = colors(names.size)
137
+ bar_areas.zip(cols).each do |rc0, col|
138
+ _, rc1, = rc0.vsplit(1,1,1)
139
+ rc, = rc1.hsplit(2,1)
140
+ fill_rect( pdf, rc, col )
141
+ end
142
+ end
143
+
144
+ def tick_unit(v)
145
+ base = (10**Math.log10(v).round).to_f
146
+ man = v/base
147
+ return 0.5*base if man<0.6
148
+ return base if man<1.2
149
+ base*2
150
+ end
151
+
152
+ def tick_values(axis, range)
153
+ min, max = range.minmax.map{ |e| scale_value( axis, e ) }
154
+ unit = tick_unit((max - min) * 0.1)
155
+ i_low = (min / unit).ceil
156
+ i_hi = (max / unit).floor
157
+ (i_low..i_hi).map{ |i| unscale_value( axis, i*unit ) }
158
+ end
159
+
160
+ def render_yticks(pdf, area, yrange, yvalues)
161
+ h = (area.h / yvalues.size) * 0.7
162
+ rects = yvalues.map{ |v|
163
+ abs_y = abs_y_positoin( v, area, yrange )
164
+ Rect.new( area.x, abs_y + h/2, area.w*0.9, h )
165
+ }
166
+ svalues = yvalues.map{ |v| "%g " % v }
167
+ draw_samesize_texts( pdf, rects, svalues, align: :right )
168
+ end
169
+
170
+ def render_y_grid(pdf, area, yrange, yvalues)
171
+ pdf.save_graphics_state do
172
+ pdf.line_width = 0.5
173
+ yvalues.each do |v|
174
+ if v==0
175
+ pdf.stroke_color "000000"
176
+ pdf.undash
177
+ else
178
+ pdf.stroke_color "888888"
179
+ pdf.dash([2,2])
180
+ end
181
+ abs_y = abs_y_positoin( v, area, yrange )
182
+ pdf.stroke_horizontal_line area.x, area.right, at: abs_y
183
+ end
184
+ end
185
+ end
186
+
187
+ end
188
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charma
4
+ class Document
5
+ def initialize( &block )
6
+ @pages = []
7
+ block[self]
8
+ end
9
+
10
+ def new_page(&block)
11
+ page = Page.new(self)
12
+ block[page]
13
+ @pages.push page
14
+ end
15
+
16
+ def render( filename )
17
+ raise "no page added" if @pages.empty?
18
+ opts = @pages.first.create_opts
19
+ Prawn::Document.generate(filename, opts) do |pdf|
20
+ @pages.each.with_index do |page,ix|
21
+ pdf.start_new_page(page.create_opts) if ix != 0
22
+ page.render(pdf)
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charma
4
+ class Error < StandardError; end
5
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charma
4
+ class LineChart < Chart
5
+ def initialize(opts)
6
+ super(opts)
7
+ end
8
+
9
+ def scaled_values(sym)
10
+ @opts[:series].map{ |e|
11
+ v = e[sym]
12
+ if v
13
+ e[sym].map{ |v| scale_value(sym, v) }
14
+ else
15
+ (1..e[:y].size).map(&:to_f)
16
+ end
17
+ }
18
+ end
19
+
20
+ def calc_range( sym )
21
+ r0 = scaled_values(sym).flatten.minmax
22
+ dist = r0[1] - r0[0]
23
+ delta = dist==0 ? 1 : dist*0.1
24
+ raw_range =
25
+ if 0<=r0[0]
26
+ [[r0[0]-delta, 0].max, r0[1]+delta]
27
+ else
28
+ [r0[0]-delta, r0[1]+delta]
29
+ end
30
+ raw_range.map{ |e| unscale_value( sym, e ) }
31
+ end
32
+
33
+ def render_series( pdf, rect, xrange, yrange, s)
34
+ xs = s[:x] || [*1..s[:y].size]
35
+ ys = s[:y]
36
+ points = xs.zip(ys).map{ |x,y|
37
+ [
38
+ abs_x_positoin( x, rect, xrange ),
39
+ abs_y_positoin( y, rect, yrange )
40
+ ]
41
+ }
42
+ pdf.stroke do
43
+ pdf.move_to( *points.first )
44
+ points.drop(1).each do |x,y|
45
+ pdf.line_to(x,y)
46
+ end
47
+ end
48
+ end
49
+
50
+ def render_chart(pdf, rect, xrange, yrange)
51
+ stroke_rect(pdf, rect)
52
+ cols = colors(@opts[:series].size)
53
+ pdf.save_graphics_state do
54
+ pdf.line_width( 4 )
55
+ @opts[:series].zip(cols).each do |s, col|
56
+ pdf.stroke_color( col )
57
+ render_series( pdf, rect, xrange, yrange, s)
58
+ end
59
+ end
60
+ end
61
+
62
+ def has_x_ticks?
63
+ if @opts[:x_ticks].nil?
64
+ !! @opts[:series].first[:x]
65
+ else
66
+ !! @opts[:x_ticks]
67
+ end
68
+ end
69
+
70
+ def render_xticks(pdf, area, xrange, xticks)
71
+ xtick_texts = @opts[:x_ticks] || xticks.map{ |e| "%g" % e }
72
+ w = area.w*0.7 / xticks.size
73
+ rects = xticks.map{ |rx|
74
+ ax = abs_x_positoin( rx, area, xrange )
75
+ Rect.new( ax-w/2, area.y, w, area.h )
76
+ }
77
+ draw_samesize_texts( pdf, rects, xtick_texts, valign: :top )
78
+ end
79
+
80
+ def render_x_grid(pdf, area, xrange, xvalues)
81
+ pdf.save_graphics_state do
82
+ pdf.line_width = 0.5
83
+ xvalues.each do |v|
84
+ if v==0
85
+ pdf.stroke_color "000000"
86
+ pdf.undash
87
+ else
88
+ pdf.stroke_color "888888"
89
+ pdf.dash([2,2])
90
+ end
91
+ abs_x = abs_x_positoin( v, area, xrange )
92
+ pdf.stroke_vertical_line area.y, area.bottom, at: abs_x
93
+ end
94
+ end
95
+ end
96
+
97
+ def tick_values(axis, range)
98
+ ticks = @opts[:"#{axis}_ticks"]
99
+ if @opts[:series].first[axis] || !ticks
100
+ super(axis, range)
101
+ else
102
+ (1..ticks.size).map(&:to_f)
103
+ end
104
+ end
105
+
106
+ def render( pdf, rect )
107
+ stroke_rect(pdf, rect)
108
+ title_text = @opts[:title]
109
+ title, main, ticks, bottom = rect.vsplit(
110
+ (title_text ? 1 : 0),
111
+ 7,
112
+ (has_x_ticks? ? 0.5 : 0),
113
+ (bottom_legend? ? 0.5 : 0))
114
+ draw_text( pdf, title, title_text ) if title_text
115
+ hratio = [(@opts[:y_label] ? 1 : 0), 1, 10]
116
+ ylabel, yticks, chart = main.hsplit(*hratio)
117
+ xrange = @opts[:x_range] || calc_range(:x)
118
+ yrange = @opts[:y_range] || calc_range(:y)
119
+ render_chart(pdf, chart, xrange, yrange)
120
+ xvalues = tick_values(:x, xrange )
121
+ if has_x_ticks?
122
+ _, _, xticks = ticks.hsplit(*hratio)
123
+ render_xticks(pdf, xticks, xrange, xvalues)
124
+ end
125
+ render_x_grid(pdf, chart, xrange, xvalues)
126
+ render_legend(pdf, bottom) if bottom_legend?
127
+ yvalues = tick_values(:y, yrange)
128
+ render_yticks(pdf, yticks, yrange, yvalues)
129
+ render_y_grid(pdf, chart, yrange, yvalues)
130
+
131
+ # end
132
+ # if @opts[:x_ticks]
133
+ # _, _, xticks = ticks.hsplit(*hratio)
134
+ # render_xticks(pdf, xticks)
135
+ # end
136
+ # yvalues = ytick_values(yrange)
137
+ # render_yticks(pdf, yticks, yrange, yvalues)
138
+ # render_y_grid(pdf, chart, yrange, yvalues)
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charma
4
+ class Page
5
+ def initialize( doc )
6
+ @doc = doc
7
+ @graphs = []
8
+ end
9
+
10
+ def create_opts
11
+ {
12
+ page_size: "A4",
13
+ page_layout: :landscape,
14
+ }
15
+ end
16
+
17
+ def split_rect( rc, pos, size )
18
+ horz = ([10,1]*size[0])[0..-2]
19
+ vrect = rc.hsplit( *horz ).select.with_index{ |_,ix| ix.even? }[pos[0]]
20
+ vert = ([10,1]*size[1])[0..-2]
21
+ vrect.vsplit( *vert ).select.with_index{ |_,ix| ix.even? }[pos[1]]
22
+ end
23
+
24
+ def area(mb, ix)
25
+ t = Rect.new(mb.left, mb.top, mb.width, mb.height)
26
+ c = @graphs.size
27
+ case c
28
+ when 1
29
+ t
30
+ when 2..3
31
+ split_rect( t, [ix,0], [c,1] )
32
+ else
33
+ w = Math.sqrt(c).ceil
34
+ h = (c.to_f/w).ceil
35
+ split_rect( t, ix.divmod(w).reverse, [w, h] )
36
+ end
37
+ end
38
+
39
+ def render(pdf)
40
+ pdf.stroke_axis
41
+ @graphs.each.with_index do |g,ix|
42
+ g.render( pdf, area(pdf.margin_box, ix) )
43
+ end
44
+ end
45
+
46
+ def add_barchart(opts)
47
+ @graphs.push BarChart.new(opts)
48
+ end
49
+
50
+ def add_linechart(opts)
51
+ @graphs.push LineChart.new(opts)
52
+ end
53
+
54
+ def add_violinchart(opts)
55
+ @graphs.push ViolinChart.new(opts)
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charma
4
+ Rect = Struct.new( :x, :y, :w, :h ) do
5
+ def vsplit( *rel_hs )
6
+ rel_sum = rel_hs.sum
7
+ abs_y = y.to_f
8
+ rel_hs.map{ |rel_h|
9
+ abs_h = rel_h.to_f * h / rel_sum
10
+ rc = Rect.new( x, abs_y, w, abs_h )
11
+ abs_y -= abs_h
12
+ rc
13
+ }
14
+ end
15
+
16
+ def hsplit( *rel_ws )
17
+ rel_sum = rel_ws.sum
18
+ abs_x = x.to_f
19
+ rel_ws.map{ |rel_w|
20
+ abs_w = rel_w.to_f * w / rel_sum
21
+ rc = Rect.new( abs_x, y, abs_w, h )
22
+ abs_x += abs_w
23
+ rc
24
+ }
25
+ end
26
+
27
+ def center
28
+ [x+w/2, y-h/2]
29
+ end
30
+
31
+ def rot90
32
+ cx, cy = center
33
+ Rect.new( cx-h/2, cy+w/2, h, w )
34
+ end
35
+
36
+ def right
37
+ x+w
38
+ end
39
+
40
+ def bottom
41
+ y-h
42
+ end
43
+
44
+ def topleft
45
+ [x,y]
46
+ end
47
+
48
+ def bottomleft
49
+ [x, y-h]
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charma
4
+ VERSION = "0.1.0"
5
+ end