charma 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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